From ebe1c3c4a322dc39b2568dfea14c6d68204aab11 Mon Sep 17 00:00:00 2001 From: Agzamus Date: Thu, 3 Sep 2026 19:41:26 +0300 Subject: [PATCH] refactor: database multi-period schedule storage --- app/build.gradle.kts | 4 + .../2.json | 381 ++++++++++++++++++ .../agzam/vyatsu/data/local/AppDatabase.kt | 8 +- .../agzam/vyatsu/data/local/Converters.kt | 29 ++ .../java/agzam/vyatsu/data/local/DayDto.kt | 23 -- .../java/agzam/vyatsu/data/local/DayEntity.kt | 27 ++ .../agzam/vyatsu/data/local/GroupEntity.kt | 8 +- .../java/agzam/vyatsu/data/local/LessonDto.kt | 38 -- .../agzam/vyatsu/data/local/LessonEntity.kt | 41 ++ .../agzam/vyatsu/data/local/ScheduleCodec.kt | 25 -- .../agzam/vyatsu/data/local/ScheduleDao.kt | 67 ++- .../agzam/vyatsu/data/local/ScheduleEntity.kt | 29 +- .../java/agzam/vyatsu/data/local/SlotDto.kt | 26 -- .../data/repository/GroupRepositoryImpl.kt | 11 +- .../data/repository/ScheduleRepositoryImpl.kt | 186 +++++++-- .../main/java/agzam/vyatsu/di/AppContainer.kt | 11 +- .../agzam/vyatsu/domain/model/Schedule.kt | 21 +- .../domain/repository/ScheduleRepository.kt | 4 +- .../vyatsu/ui/schedule/ScheduleScreen.kt | 11 + .../vyatsu/ui/schedule/ScheduleViewModel.kt | 17 + 20 files changed, 777 insertions(+), 190 deletions(-) create mode 100644 app/schemas/agzam.vyatsu.data.local.AppDatabase/2.json create mode 100644 app/src/main/java/agzam/vyatsu/data/local/Converters.kt delete mode 100644 app/src/main/java/agzam/vyatsu/data/local/DayDto.kt create mode 100644 app/src/main/java/agzam/vyatsu/data/local/DayEntity.kt delete mode 100644 app/src/main/java/agzam/vyatsu/data/local/LessonDto.kt create mode 100644 app/src/main/java/agzam/vyatsu/data/local/LessonEntity.kt delete mode 100644 app/src/main/java/agzam/vyatsu/data/local/ScheduleCodec.kt delete mode 100644 app/src/main/java/agzam/vyatsu/data/local/SlotDto.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 648dd19..d719a3a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,6 +65,10 @@ android { } } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/app/schemas/agzam.vyatsu.data.local.AppDatabase/2.json b/app/schemas/agzam.vyatsu.data.local.AppDatabase/2.json new file mode 100644 index 0000000..4091d90 --- /dev/null +++ b/app/schemas/agzam.vyatsu.data.local.AppDatabase/2.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/data/local/AppDatabase.kt b/app/src/main/java/agzam/vyatsu/data/local/AppDatabase.kt index fb471df..10d5371 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/AppDatabase.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/AppDatabase.kt @@ -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 diff --git a/app/src/main/java/agzam/vyatsu/data/local/Converters.kt b/app/src/main/java/agzam/vyatsu/data/local/Converters.kt new file mode 100644 index 0000000..5718151 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/data/local/Converters.kt @@ -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) +} diff --git a/app/src/main/java/agzam/vyatsu/data/local/DayDto.kt b/app/src/main/java/agzam/vyatsu/data/local/DayDto.kt deleted file mode 100644 index 9c0e4ab..0000000 --- a/app/src/main/java/agzam/vyatsu/data/local/DayDto.kt +++ /dev/null @@ -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 = 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) } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/data/local/DayEntity.kt b/app/src/main/java/agzam/vyatsu/data/local/DayEntity.kt new file mode 100644 index 0000000..8c64ac2 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/data/local/DayEntity.kt @@ -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 +) diff --git a/app/src/main/java/agzam/vyatsu/data/local/GroupEntity.kt b/app/src/main/java/agzam/vyatsu/data/local/GroupEntity.kt index e746563..3c3f2a5 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/GroupEntity.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/GroupEntity.kt @@ -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 ) diff --git a/app/src/main/java/agzam/vyatsu/data/local/LessonDto.kt b/app/src/main/java/agzam/vyatsu/data/local/LessonDto.kt deleted file mode 100644 index 958c75c..0000000 --- a/app/src/main/java/agzam/vyatsu/data/local/LessonDto.kt +++ /dev/null @@ -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 - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/data/local/LessonEntity.kt b/app/src/main/java/agzam/vyatsu/data/local/LessonEntity.kt new file mode 100644 index 0000000..bc63603 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/data/local/LessonEntity.kt @@ -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? +) diff --git a/app/src/main/java/agzam/vyatsu/data/local/ScheduleCodec.kt b/app/src/main/java/agzam/vyatsu/data/local/ScheduleCodec.kt deleted file mode 100644 index cf1449d..0000000 --- a/app/src/main/java/agzam/vyatsu/data/local/ScheduleCodec.kt +++ /dev/null @@ -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>(type) - - fun toJson(days: List): String { - val dto = days.map { DayDto.fromDomain(it) } - return adapter.toJson(dto) - } - - fun fromJson(json: String): List { - return try { - val dto = adapter.fromJson(json) ?: return emptyList() - dto.map { it.toDomain() } - } catch (t: Throwable) { - emptyList() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/data/local/ScheduleDao.kt b/app/src/main/java/agzam/vyatsu/data/local/ScheduleDao.kt index e5e1f11..eb055a0 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/ScheduleDao.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/ScheduleDao.kt @@ -8,15 +8,68 @@ import kotlinx.coroutines.flow.Flow @Dao interface ScheduleDao { - @Query("SELECT * FROM schedules WHERE groupId = :groupId") - fun observe(groupId: String): Flow - @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> - @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 + + @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) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLessons(lessons: List) + + @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 + + @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 + + @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> + + @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> } diff --git a/app/src/main/java/agzam/vyatsu/data/local/ScheduleEntity.kt b/app/src/main/java/agzam/vyatsu/data/local/ScheduleEntity.kt index 8276f8d..4c255df 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/ScheduleEntity.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/ScheduleEntity.kt @@ -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 ) diff --git a/app/src/main/java/agzam/vyatsu/data/local/SlotDto.kt b/app/src/main/java/agzam/vyatsu/data/local/SlotDto.kt deleted file mode 100644 index 36eba34..0000000 --- a/app/src/main/java/agzam/vyatsu/data/local/SlotDto.kt +++ /dev/null @@ -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 = 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) } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/data/repository/GroupRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/GroupRepositoryImpl.kt index d3b7d18..e588d78 100644 --- a/app/src/main/java/agzam/vyatsu/data/repository/GroupRepositoryImpl.kt +++ b/app/src/main/java/agzam/vyatsu/data/repository/GroupRepositoryImpl.kt @@ -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( diff --git a/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt index a80e55f..9c5df46 100644 --- a/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt +++ b/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt @@ -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 = - 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( - groupId = groupId, - season = season.name, - dateRangeFirst = range.first, - dateRangeSecond = range.second, - weeksJson = codec.toJson(days), - lastSyncedAt = now() + 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, + 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,126 @@ 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) { + scheduleDao.deleteDaysForSchedule(scheduleId) + val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) } + val dayIdByDate = mutableMapOf() + scheduleDao.insertDays(dayEntities) + for (day in dayEntities) { + dayIdByDate[LocalDate.ofEpochDay(day.date)] = day.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, + dayEntities: List, + lessonEntities: List + ): 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 now(): Long = System.currentTimeMillis() + private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute - private fun ScheduleEntity.toDomain(): Schedule = Schedule( - groupId = groupId, - season = Season.valueOf(season), - days = codec.fromJson(weeksJson) - ) + private fun now(): Long = System.currentTimeMillis() 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") } } diff --git a/app/src/main/java/agzam/vyatsu/di/AppContainer.kt b/app/src/main/java/agzam/vyatsu/di/AppContainer.kt index fccfd50..11c7ab1 100644 --- a/app/src/main/java/agzam/vyatsu/di/AppContainer.kt +++ b/app/src/main/java/agzam/vyatsu/di/AppContainer.kt @@ -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 ) } diff --git a/app/src/main/java/agzam/vyatsu/domain/model/Schedule.kt b/app/src/main/java/agzam/vyatsu/domain/model/Schedule.kt index 7e1b36c..4d24b76 100644 --- a/app/src/main/java/agzam/vyatsu/domain/model/Schedule.kt +++ b/app/src/main/java/agzam/vyatsu/domain/model/Schedule.kt @@ -1,17 +1,18 @@ 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, val days: List - ) { - + fun periodOf(date: LocalDate): SchedulePeriod? = + periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) } } diff --git a/app/src/main/java/agzam/vyatsu/domain/repository/ScheduleRepository.kt b/app/src/main/java/agzam/vyatsu/domain/repository/ScheduleRepository.kt index 8437e20..9d1abf8 100644 --- a/app/src/main/java/agzam/vyatsu/domain/repository/ScheduleRepository.kt +++ b/app/src/main/java/agzam/vyatsu/domain/repository/ScheduleRepository.kt @@ -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) } diff --git a/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleScreen.kt b/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleScreen.kt index 38e6dcb..8454d94 100644 --- a/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleScreen.kt +++ b/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleScreen.kt @@ -94,6 +94,17 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) { "$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)) DayChips( diff --git a/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleViewModel.kt b/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleViewModel.kt index ee00ef7..6dd5e75 100644 --- a/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleViewModel.kt @@ -93,6 +93,23 @@ class ScheduleViewModel( _uiState.value = _uiState.value.copy(selectedIndex = index) } + fun clearAll() { + val groupId = currentGroupId ?: return + viewModelScope.launch { + scheduleRepository.clearAll(groupId) + } + } + + fun clearSelectedPeriod() { + val state = _uiState.value + val schedule = state.schedule ?: return + val day = schedule.days.getOrNull(state.selectedIndex) ?: return + val period = schedule.periodOf(day.date) ?: return + viewModelScope.launch { + scheduleRepository.clearPeriod(schedule.groupId, period) + } + } + private fun todayIndex(days: List): Int { if (days.isEmpty()) return 0 val today = ScheduleDates.moscowToday()