refactor: database multi-period schedule storage

This commit is contained in:
2026-09-03 19:41:26 +03:00
parent 47ca253d6a
commit ebe1c3c4a3
20 changed files with 777 additions and 190 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?
)
@@ -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>)
@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(
@@ -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,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<Day>) {
scheduleDao.deleteDaysForSchedule(scheduleId)
val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) }
val dayIdByDate = mutableMapOf<LocalDate, Long>()
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<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,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<SchedulePeriod>,
val days: List<Day>
) {
fun periodOf(date: LocalDate): SchedulePeriod? =
periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) }
}
@@ -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)
}
@@ -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(
@@ -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<Day>): Int {
if (days.isEmpty()) return 0
val today = ScheduleDates.moscowToday()