Compare commits

...
Author SHA1 Message Date
Agzamus 1fc43bb8fc refactor: clenup 2026-09-03 20:54:44 +03:00
Agzamus 32f93197ab feat: week select 2026-09-03 20:53:17 +03:00
Agzamus 32d0cec8ce feat: removed auto sync 2026-09-03 20:46:11 +03:00
Agzamus 72e63addb2 fix: FOREIGN KEY constraint failed 2026-09-03 20:41:03 +03:00
Agzamus ebe1c3c4a3 refactor: database multi-period schedule storage 2026-09-03 19:41:26 +03:00
23 changed files with 965 additions and 245 deletions
+4
View File
@@ -65,6 +65,10 @@ android {
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -0,0 +1,381 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "48411ce0f1f22ef1dccd5852a9e97c5d",
"entities": [
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `autumnFirst` INTEGER, `autumnSecond` INTEGER, `springFirst` INTEGER, `springSecond` INTEGER, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "faculty",
"columnName": "faculty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "autumnFirst",
"columnName": "autumnFirst",
"affinity": "INTEGER"
},
{
"fieldPath": "autumnSecond",
"columnName": "autumnSecond",
"affinity": "INTEGER"
},
{
"fieldPath": "springFirst",
"columnName": "springFirst",
"affinity": "INTEGER"
},
{
"fieldPath": "springSecond",
"columnName": "springSecond",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "schedules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_schedules_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
},
{
"name": "index_schedules_groupId_season_weekStart",
"unique": true,
"columnNames": [
"groupId",
"season",
"weekStart"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "days",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleId",
"columnName": "scheduleId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_days_scheduleId",
"unique": false,
"columnNames": [
"scheduleId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
},
{
"name": "index_days_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
}
],
"foreignKeys": [
{
"table": "schedules",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"scheduleId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "lessons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayId",
"columnName": "dayId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slotIndex",
"columnName": "slotIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lessonIndex",
"columnName": "lessonIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT"
},
{
"fieldPath": "groupName",
"columnName": "groupName",
"affinity": "TEXT"
},
{
"fieldPath": "subgroup",
"columnName": "subgroup",
"affinity": "TEXT"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_lessons_dayId",
"unique": false,
"columnNames": [
"dayId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
},
{
"name": "index_lessons_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
},
{
"name": "index_lessons_subject",
"unique": false,
"columnNames": [
"subject"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
},
{
"name": "index_lessons_room",
"unique": false,
"columnNames": [
"room"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
}
],
"foreignKeys": [
{
"table": "days",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"dayId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "preferences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '48411ce0f1f22ef1dccd5852a9e97c5d')"
]
}
}
@@ -2,16 +2,20 @@ package agzam.vyatsu.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [
GroupEntity::class,
ScheduleEntity::class,
DayEntity::class,
LessonEntity::class,
PreferenceEntity::class
],
version = 1,
exportSchema = false
version = 2,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun groupDao(): GroupDao
abstract fun scheduleDao(): ScheduleDao
@@ -0,0 +1,29 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.TypeConverter
import java.time.LocalDate
import java.time.LocalTime
class Converters {
@TypeConverter
fun localDateToEpochDay(date: LocalDate?): Long? = date?.toEpochDay()
@TypeConverter
fun epochDayToLocalDate(epochDay: Long?): LocalDate? = epochDay?.let(LocalDate::ofEpochDay)
@TypeConverter
fun localTimeToMinutes(time: LocalTime?): Int? = time?.let { it.hour * 60 + it.minute }
@TypeConverter
fun minutesToLocalTime(minutes: Int?): LocalTime? =
minutes?.let { LocalTime.of(it / 60, it % 60) }
@TypeConverter
fun lessonTypeToString(type: LessonType): String = type.name
@TypeConverter
fun stringToLessonType(value: String): LessonType =
runCatching { LessonType.valueOf(value) }.getOrDefault(LessonType.OTHER)
}
@@ -1,23 +0,0 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Day
import com.squareup.moshi.JsonClass
import java.time.LocalDate
@JsonClass(generateAdapter = true)
data class DayDto(
val date: String,
val slots: List<SlotDto> = emptyList()
) {
fun toDomain(): Day = Day(
date = LocalDate.parse(date),
intervals = slots.map { it.toDomain() }
)
companion object {
fun fromDomain(d: Day): DayDto = DayDto(
date = d.date.toString(),
slots = d.intervals.map { SlotDto.fromDomain(it) }
)
}
}
@@ -0,0 +1,27 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "days",
foreignKeys = [
ForeignKey(
entity = ScheduleEntity::class,
parentColumns = ["id"],
childColumns = ["scheduleId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("scheduleId"),
Index("date")
]
)
data class DayEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val scheduleId: Long,
val date: Long
)
@@ -8,9 +8,9 @@ data class GroupEntity(
@PrimaryKey val id: String,
val name: String,
val faculty: String,
val autumnFirst: String,
val autumnSecond: String,
val springFirst: String,
val springSecond: String,
val autumnFirst: Long?,
val autumnSecond: Long?,
val springFirst: Long?,
val springSecond: Long?,
val lastSyncedAt: Long
)
@@ -1,38 +0,0 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LessonDto(
val raw: String = "",
val room: String? = null,
val group: String? = null,
val subgroup: String? = null,
val subject: String? = null,
val type: String = "",
val teacher: String? = null
) {
fun toDomain(): Lesson = Lesson(
raw = raw,
room = room?.takeIf { it.isNotBlank() },
group = group?.takeIf { it.isNotBlank() },
subgroup = subgroup?.takeIf { it.isNotBlank() },
subject = subject?.takeIf { it.isNotBlank() },
type = runCatching { LessonType.valueOf(type) }.getOrDefault(LessonType.OTHER),
teacher = teacher?.takeIf { it.isNotBlank() }
)
companion object {
fun fromDomain(l: Lesson): LessonDto = LessonDto(
raw = l.raw,
room = l.room,
group = l.group,
subgroup = l.subgroup,
subject = l.subject,
type = l.type.name,
teacher = l.teacher
)
}
}
@@ -0,0 +1,41 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "lessons",
foreignKeys = [
ForeignKey(
entity = DayEntity::class,
parentColumns = ["id"],
childColumns = ["dayId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("dayId"),
Index("date"),
Index("subject"),
Index("room")
]
)
data class LessonEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val dayId: Long,
val date: Long,
val slotIndex: Int,
val start: Int,
val end: Int,
val lessonIndex: Int,
val raw: String,
val room: String?,
val groupName: String?,
val subgroup: String?,
val subject: String?,
val type: LessonType,
val teacher: String?
)
@@ -15,4 +15,16 @@ interface PreferencesDao {
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
suspend fun clearSelectedGroup()
@Query("SELECT value FROM preferences WHERE key = 'selectedWeekStart'")
suspend fun selectedWeekStart(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedWeekStart', :weekStart)")
suspend fun setSelectedWeekStart(weekStart: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedDayDate'")
suspend fun selectedDayDate(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
suspend fun setSelectedDayDate(date: String)
}
@@ -1,25 +0,0 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Day
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
class ScheduleCodec(moshi: Moshi) {
private val type: java.lang.reflect.Type =
Types.newParameterizedType(List::class.java, DayDto::class.java)
private val adapter = moshi.adapter<List<DayDto>>(type)
fun toJson(days: List<Day>): String {
val dto = days.map { DayDto.fromDomain(it) }
return adapter.toJson(dto)
}
fun fromJson(json: String): List<Day> {
return try {
val dto = adapter.fromJson(json) ?: return emptyList()
dto.map { it.toDomain() }
} catch (t: Throwable) {
emptyList()
}
}
}
@@ -8,15 +8,68 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface ScheduleDao {
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
fun observe(groupId: String): Flow<ScheduleEntity?>
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
suspend fun get(groupId: String): ScheduleEntity?
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
fun observeSchedules(groupId: String): Flow<List<ScheduleEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ScheduleEntity)
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
suspend fun getSchedules(groupId: String): List<ScheduleEntity>
@Query("DELETE FROM days WHERE scheduleId = :scheduleId")
suspend fun deleteDaysForSchedule(scheduleId: Long)
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertSchedule(schedule: ScheduleEntity): Long
@Query("DELETE FROM schedules WHERE groupId = :groupId AND season = :season AND weekStart = :weekStart")
suspend fun deleteSchedule(groupId: String, season: String, weekStart: Long)
@Query("DELETE FROM schedules WHERE groupId = :groupId")
suspend fun delete(groupId: String)
suspend fun deleteAll(groupId: String)
@Query(
"""
DELETE FROM schedules
WHERE groupId = :groupId
AND id NOT IN (
SELECT id FROM schedules
WHERE groupId = :groupId
ORDER BY weekStart DESC
LIMIT :keep
)
"""
)
suspend fun trim(groupId: String, keep: Int)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDays(days: List<DayEntity>): List<Long>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLessons(lessons: List<LessonEntity>)
@Query(
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY days.date"
)
suspend fun getDaysForGroup(groupId: String): List<DayEntity>
@Query(
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
)
suspend fun getLessonsForGroup(groupId: String): List<LessonEntity>
@Query(
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY days.date"
)
fun observeDaysForGroup(groupId: String): Flow<List<DayEntity>>
@Query(
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
)
fun observeLessonsForGroup(groupId: String): Flow<List<LessonEntity>>
}
@@ -1,14 +1,31 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "schedules")
@Entity(
tableName = "schedules",
foreignKeys = [
ForeignKey(
entity = GroupEntity::class,
parentColumns = ["id"],
childColumns = ["groupId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("groupId"),
Index(value = ["groupId", "season", "weekStart"], unique = true)
]
)
data class ScheduleEntity(
@PrimaryKey val groupId: String,
val season: String,
val dateRangeFirst: String,
val dateRangeSecond: String,
val weeksJson: String,
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val groupId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long,
val lastSyncedAt: Long
)
@@ -1,26 +0,0 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.TimeSlot
import com.squareup.moshi.JsonClass
import java.time.LocalTime
@JsonClass(generateAdapter = true)
data class SlotDto(
val start: String,
val end: String,
val lessons: List<LessonDto> = emptyList()
) {
fun toDomain(): TimeSlot = TimeSlot(
start = LocalTime.parse(start),
end = LocalTime.parse(end),
lessons = lessons.map { it.toDomain() }
)
companion object {
fun fromDomain(s: TimeSlot): SlotDto = SlotDto(
start = s.start.toString(),
end = s.end.toString(),
lessons = s.lessons.map { LessonDto.fromDomain(it) }
)
}
}
@@ -5,6 +5,7 @@ import agzam.vyatsu.data.local.GroupEntity
import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.repository.GroupRepository
@@ -50,16 +51,18 @@ class GroupRepositoryImpl(
id = group.id,
name = group.name,
faculty = group.faculty,
autumnFirst = autumn?.first ?: "",
autumnSecond = autumn?.second ?: "",
springFirst = spring?.first ?: "",
springSecond = spring?.second ?: "",
autumnFirst = autumn?.first?.let(::epochDay),
autumnSecond = autumn?.second?.let(::epochDay),
springFirst = spring?.first?.let(::epochDay),
springSecond = spring?.second?.let(::epochDay),
lastSyncedAt = now()
)
}
groupDao.insertAll(entities)
}
private fun epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay()
private fun now(): Long = System.currentTimeMillis()
private fun GroupEntity.toDomain(): Group = Group(
@@ -9,4 +9,8 @@ class PreferencesRepositoryImpl(
override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup()
override suspend fun selectedWeekStart(): String? = dao.selectedWeekStart()
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
}
@@ -1,6 +1,7 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.ScheduleCodec
import agzam.vyatsu.data.local.DayEntity
import agzam.vyatsu.data.local.LessonEntity
import agzam.vyatsu.data.local.ScheduleDao
import agzam.vyatsu.data.local.ScheduleEntity
import agzam.vyatsu.data.local.GroupDao
@@ -9,39 +10,50 @@ import agzam.vyatsu.data.remote.pdf.PdfScheduleParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.model.TimeSlot
import agzam.vyatsu.domain.repository.ScheduleRepository
import android.util.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import java.time.LocalDate
import java.time.LocalTime
class ScheduleRepositoryImpl(
private val scheduleDao: ScheduleDao,
private val groupDao: GroupDao,
private val codec: ScheduleCodec
private val groupDao: GroupDao
) : ScheduleRepository {
override fun observeSchedule(groupId: String): Flow<Schedule?> =
scheduleDao.observe(groupId).map { it?.toDomain() }
combine(
scheduleDao.observeSchedules(groupId),
scheduleDao.observeDaysForGroup(groupId),
scheduleDao.observeLessonsForGroup(groupId)
) { schedules, days, lessons ->
buildSchedule(schedules, days, lessons)
}
override suspend fun getSchedule(groupId: String): Schedule? =
scheduleDao.get(groupId)?.toDomain()
buildSchedule(
scheduleDao.getSchedules(groupId),
scheduleDao.getDaysForGroup(groupId),
scheduleDao.getLessonsForGroup(groupId)
)
override suspend fun sync(groupId: String): Schedule? {
Log.d(TAG, "sync(groupId=$groupId) start")
val group = groupDao.getById(groupId)
if (group == null) {
val group = groupDao.getById(groupId) ?: run {
Log.w(TAG, "sync: group $groupId not found in DB")
return null
}
val season = ScheduleDates.currentSeason()
val range = dateRangeFor(group, season)
if (range == null) {
Log.w(TAG, "sync: no date range for season $season (group ${group.id}, " +
"autumn=[${group.autumnFirst}, ${group.autumnSecond}], " +
"spring=[${group.springFirst}, ${group.springSecond}])")
val range = dateRangeFor(group, season) ?: run {
Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
return null
}
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
@@ -55,17 +67,26 @@ class ScheduleRepositoryImpl(
val days = PdfScheduleParser.parse(bytes)
Log.d(TAG, "sync: parsed ${days.size} days")
val entity = ScheduleEntity(
val weekStart = ScheduleDates.asDate(range.first).toEpochDay()
val weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
val existing = scheduleDao.getSchedules(groupId)
.firstOrNull { it.season == season && it.weekStart == weekStart }
val scheduleId = if (existing != null) existing.id
else scheduleDao.insertSchedule(
ScheduleEntity(
groupId = groupId,
season = season.name,
dateRangeFirst = range.first,
dateRangeSecond = range.second,
weeksJson = codec.toJson(days),
season = season,
weekStart = weekStart,
weekEnd = weekEnd,
lastSyncedAt = now()
)
scheduleDao.upsert(entity)
Log.d(TAG, "sync: upserted schedule for $groupId")
entity.toDomain()
)
replaceDays(scheduleId, days)
scheduleDao.trim(groupId, MAX_PERIODS)
getSchedule(groupId)
} catch (e: Throwable) {
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
null
@@ -73,33 +94,123 @@ class ScheduleRepositoryImpl(
}
override suspend fun isStale(groupId: String): Boolean {
val entity = scheduleDao.get(groupId) ?: return true
return now() - entity.lastSyncedAt > STALE_WINDOW_MS
val schedules = scheduleDao.getSchedules(groupId)
val latest = schedules.maxByOrNull { it.weekStart } ?: return true
return now() - latest.lastSyncedAt > STALE_WINDOW_MS
}
override suspend fun clear(groupId: String) = scheduleDao.delete(groupId)
override suspend fun clearPeriod(groupId: String, period: SchedulePeriod) {
scheduleDao.deleteSchedule(
groupId = groupId,
season = period.season.name,
weekStart = period.weekStart.toEpochDay()
)
}
override suspend fun clearAll(groupId: String) = scheduleDao.deleteAll(groupId)
private suspend fun replaceDays(scheduleId: Long, days: List<Day>) {
scheduleDao.deleteDaysForSchedule(scheduleId)
val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) }
val dayIds = scheduleDao.insertDays(dayEntities)
val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) -> LocalDate.ofEpochDay(day.date) to id }
val lessons = days.flatMap { day ->
day.intervals.flatMapIndexed { slotIndex, slot ->
slot.lessons.mapIndexed { lessonIndex, lesson ->
LessonEntity(
dayId = dayIdByDate.getValue(day.date),
date = day.date.toEpochDay(),
slotIndex = slotIndex,
start = slot.start.toMinutesOfDay(),
end = slot.end.toMinutesOfDay(),
lessonIndex = lessonIndex,
raw = lesson.raw,
room = lesson.room,
groupName = lesson.group,
subgroup = lesson.subgroup,
subject = lesson.subject,
type = lesson.type,
teacher = lesson.teacher
)
}
}
}
scheduleDao.insertLessons(lessons)
}
private fun buildSchedule(
schedules: List<ScheduleEntity>,
dayEntities: List<DayEntity>,
lessonEntities: List<LessonEntity>
): Schedule? {
if (schedules.isEmpty()) return null
val groupId = schedules.first().groupId
val periods = schedules.sortedBy { it.weekStart }.map {
SchedulePeriod(
weekStart = LocalDate.ofEpochDay(it.weekStart),
weekEnd = LocalDate.ofEpochDay(it.weekEnd),
season = it.season
)
}
val lessonsByDay = lessonEntities.groupBy { it.dayId }
val days = dayEntities
.sortedBy { it.date }
.map { day ->
val lessons = lessonsByDay[day.id].orEmpty()
.groupBy { it.slotIndex }
.toSortedMap()
.map { (_, slotLessons) ->
TimeSlot(
start = LocalTime.of(slotLessons.first().start / 60, slotLessons.first().start % 60),
end = LocalTime.of(slotLessons.first().end / 60, slotLessons.first().end % 60),
lessons = slotLessons
.sortedBy { it.lessonIndex }
.map { it.toDomain() }
)
}
Day(date = LocalDate.ofEpochDay(day.date), intervals = lessons)
}
return Schedule(groupId = groupId, periods = periods, days = days)
}
private fun LessonEntity.toDomain(): Lesson = Lesson(
raw = raw,
room = room,
group = groupName,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
return when (season) {
Season.AUTUMN -> if (group.autumnFirst.isNotEmpty() && group.autumnSecond.isNotEmpty()) {
DateRange(group.autumnFirst, group.autumnSecond)
Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) {
DateRange(
LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT)
)
} else null
Season.SPRING -> if (group.springFirst.isNotEmpty() && group.springSecond.isNotEmpty()) {
DateRange(group.springFirst, group.springSecond)
Season.SPRING -> if (group.springFirst != null && group.springSecond != null) {
DateRange(
LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT)
)
} else null
}
}
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun now(): Long = System.currentTimeMillis()
private fun ScheduleEntity.toDomain(): Schedule = Schedule(
groupId = groupId,
season = Season.valueOf(season),
days = codec.fromJson(weeksJson)
)
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
private const val MAX_PERIODS = 3
private const val TAG = "ScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
}
}
@@ -1,7 +1,6 @@
package agzam.vyatsu.di
import agzam.vyatsu.data.local.AppDatabase
import agzam.vyatsu.data.local.ScheduleCodec
import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.repository.GroupRepositoryImpl
@@ -12,18 +11,15 @@ import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository
import android.content.Context
import androidx.room.Room
import com.squareup.moshi.Moshi
class AppContainer(context: Context) {
private val moshi: Moshi = Moshi.Builder().build()
private val database: AppDatabase = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"vyatsu_schedule.db"
).build()
private val codec: ScheduleCodec = ScheduleCodec(moshi)
)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
val groupRepository: GroupRepository by lazy {
GroupRepositoryImpl(
@@ -35,7 +31,6 @@ class AppContainer(context: Context) {
ScheduleRepositoryImpl(
scheduleDao = database.scheduleDao(),
groupDao = database.groupDao(),
codec = codec
)
}
@@ -1,17 +1,21 @@
package agzam.vyatsu.domain.model
/**
* Расписание группы, как правило содержит 2 недели
* @author Agzam
*/
import java.time.LocalDate
data class SchedulePeriod(
val weekStart: LocalDate,
val weekEnd: LocalDate,
val season: Season
)
data class Schedule(
val groupId: String,
val season: Season,
/** Может содержать более 7 дней */
val periods: List<SchedulePeriod>,
val days: List<Day>
) {
fun periodOf(date: LocalDate): SchedulePeriod? =
periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) }
fun daysOf(period: SchedulePeriod): List<Day> =
days.filter { !it.date.isBefore(period.weekStart) && !it.date.isAfter(period.weekEnd) }
}
@@ -4,4 +4,8 @@ interface PreferencesRepository {
suspend fun selectedGroupId(): String?
suspend fun setSelectedGroup(groupId: String)
suspend fun clearSelectedGroup()
suspend fun selectedWeekStart(): String?
suspend fun setSelectedWeekStart(weekStart: String)
suspend fun selectedDayDate(): String?
suspend fun setSelectedDayDate(date: String)
}
@@ -1,6 +1,7 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.model.SchedulePeriod
import kotlinx.coroutines.flow.Flow
interface ScheduleRepository {
@@ -8,5 +9,6 @@ interface ScheduleRepository {
suspend fun getSchedule(groupId: String): Schedule?
suspend fun sync(groupId: String): Schedule?
suspend fun isStale(groupId: String): Boolean
suspend fun clear(groupId: String)
suspend fun clearPeriod(groupId: String, period: SchedulePeriod)
suspend fun clearAll(groupId: String)
}
@@ -1,6 +1,7 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -35,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@@ -83,23 +86,48 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
else -> {
val schedule = state.schedule!!
val day = schedule.days.getOrNull(state.selectedIndex)
val weekDays = state.weekStart
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
?.let { schedule.daysOf(it) }
.orEmpty()
val day = schedule.days.firstOrNull { it.date == state.dayDate }
if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
val isToday = state.selectedIndex == state.todayIndex
val dayLabel = if (isToday) "Сегодня " else ""
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
Text(
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(onClick = { viewModel.clearSelectedPeriod() }) {
Text("Очистить эту неделю")
}
TextButton(onClick = { viewModel.clearAll() }) {
Text("Очистить всё")
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
WeekChips(
periods = schedule.periods,
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
onSelect = viewModel::selectWeek
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
DayChips(
days = schedule.days,
selectedIndex = state.selectedIndex,
todayIndex = state.todayIndex,
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
onSelect = viewModel::selectDay
)
@@ -125,19 +153,56 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
@Composable
private fun DayChips(
days: List<Day>,
selectedIndex: Int,
todayIndex: Int,
onSelect: (Int) -> Unit
private fun WeekChips(
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
itemsIndexed(days) { index, day ->
val selected = selectedIndex == index
val isTodayChip = todayIndex == index
items(periods, key = { it.weekStart.toEpochDay() }) { period ->
val selected = selectedWeekStart == period.weekStart
val isTodayWeek = todayWeekStart == period.weekStart
FilterChip(
selected = selected,
onClick = { onSelect(index) },
onClick = { onSelect(period.weekStart) },
label = {
Text(
weekLabel(period),
style = MaterialTheme.typography.labelLarge
)
},
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayWeek && !selected) {
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
)
} else {
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
},
shape = RoundedCornerShape(8.dp)
)
}
}
}
@Composable
private fun DayChips(
days: List<Day>,
selectedDate: LocalDate?,
todayDate: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date
FilterChip(
selected = selected,
onClick = { onSelect(day.date) },
label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
@@ -172,6 +237,11 @@ private fun DayChips(
}
}
private fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@Composable
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
Column(Modifier.fillMaxWidth()) {
@@ -190,8 +260,12 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
style = MaterialTheme.typography.bodyLarge
)
Text(
listOf(lesson.type.label, lesson.subgroup, lesson.teacher, lesson.room)
.filterNotNull()
listOfNotNull(
lesson.type.label,
lesson.subgroup,
lesson.teacher,
lesson.room
)
.filter { it.isNotBlank() }
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@@ -1,7 +1,6 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository
@@ -11,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.LocalDate
class ScheduleViewModel(
private val scheduleRepository: ScheduleRepository,
@@ -37,31 +37,102 @@ class ScheduleViewModel(
currentGroupId = groupId
viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId)
_uiState.value = _uiState.value.copy(noGroup = false)
val savedWeekStart = runCatching {
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
}.getOrNull()
val savedDayDate = runCatching {
preferencesRepository.selectedDayDate()?.let(LocalDate::parse)
}.getOrNull()
_uiState.value = _uiState.value.copy(
noGroup = false,
weekStart = savedWeekStart,
dayDate = savedDayDate
)
launch {
scheduleRepository.observeSchedule(groupId).collect { schedule ->
currentGroupId?.let {
val days = schedule?.days.orEmpty()
val today = todayIndex(days)
val current = _uiState.value
val selected = current.selectedIndex.takeIf { i -> i in days.indices } ?: today
_uiState.value = current.copy(
schedule = schedule,
todayIndex = today,
selectedIndex = selected
)
}
currentGroupId?.let { applySchedule(schedule) }
}
}
launch {
syncIfMissingToday(groupId)
}
}
}
private suspend fun applySchedule(schedule: Schedule?) {
val current = _uiState.value
if (schedule == null || schedule.periods.isEmpty()) {
_uiState.value = current.copy(schedule = schedule, todayDate = null)
return
}
val today = ScheduleDates.moscowToday()
val todayDate = if (schedule.periodOf(today) != null) today else null
val weekStart = when {
schedule.periods.any { it.weekStart == current.weekStart } -> current.weekStart!!
todayDate != null -> schedule.periodOf(todayDate)!!.weekStart
else -> schedule.periods.first().weekStart
}
val period = schedule.periods.first { it.weekStart == weekStart }
val weekDays = schedule.daysOf(period)
val dayDate = when {
weekDays.any { it.date == current.dayDate } -> current.dayDate
weekDays.any { it.date == todayDate } -> todayDate
else -> weekDays.firstOrNull()?.date
}
preferencesRepository.setSelectedWeekStart(weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
_uiState.value = current.copy(
schedule = schedule,
weekStart = weekStart,
dayDate = dayDate,
todayDate = todayDate
)
}
fun selectWeek(weekStart: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
viewModelScope.launch {
val today = ScheduleDates.moscowToday()
val weekDays = schedule.daysOf(period)
val dayDate = when {
weekDays.any { it.date == today } -> today
else -> weekDays.firstOrNull()?.date
}
preferencesRepository.setSelectedWeekStart(weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
_uiState.value = _uiState.value.copy(weekStart = weekStart, dayDate = dayDate)
}
}
fun selectDay(date: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
val period = schedule.periodOf(date) ?: return
viewModelScope.launch {
preferencesRepository.setSelectedWeekStart(period.weekStart.toString())
preferencesRepository.setSelectedDayDate(date.toString())
_uiState.value = _uiState.value.copy(weekStart = period.weekStart, dayDate = date)
}
}
private suspend fun syncIfMissingToday(groupId: String) {
val schedule = scheduleRepository.getSchedule(groupId)
val today = ScheduleDates.moscowToday()
val hasTodayPeriod = schedule?.periodOf(today) != null
if (hasTodayPeriod) return
_uiState.value = _uiState.value.copy(isSyncing = true)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isSyncing = false)
}
}
}
fun switchGroup() {
viewModelScope.launch {
@@ -76,30 +147,25 @@ class ScheduleViewModel(
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
scheduleRepository.sync(groupId)
val current = _uiState.value
val days = current.schedule?.days.orEmpty()
val today = todayIndex(days)
_uiState.value = current.copy(
isRefreshing = false,
todayIndex = today,
selectedIndex = current.selectedIndex.takeIf { it in days.indices } ?: today
)
_uiState.value = _uiState.value.copy(isRefreshing = false)
}
}
fun selectDay(index: Int) {
val days = _uiState.value.schedule?.days.orEmpty()
if (index !in days.indices) return
_uiState.value = _uiState.value.copy(selectedIndex = index)
fun clearAll() {
val groupId = currentGroupId ?: return
viewModelScope.launch {
scheduleRepository.clearAll(groupId)
}
}
private fun todayIndex(days: List<Day>): Int {
if (days.isEmpty()) return 0
val today = ScheduleDates.moscowToday()
val exact = days.indexOfFirst { it.date == today }
if (exact >= 0) return exact
val next = days.indexOfFirst { !it.date.isBefore(today) }
return if (next >= 0) next else days.lastIndex
fun clearSelectedPeriod() {
val state = _uiState.value
val schedule = state.schedule ?: return
val weekStart = state.weekStart ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
viewModelScope.launch {
scheduleRepository.clearPeriod(schedule.groupId, period)
}
}
}
@@ -108,6 +174,7 @@ data class ScheduleUiState(
val isRefreshing: Boolean = false,
val isSyncing: Boolean = false,
val schedule: Schedule? = null,
val todayIndex: Int = 0,
val selectedIndex: Int = 0
val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null
)