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 { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.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.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database( @Database(
entities = [ entities = [
GroupEntity::class, GroupEntity::class,
ScheduleEntity::class, ScheduleEntity::class,
DayEntity::class,
LessonEntity::class,
PreferenceEntity::class PreferenceEntity::class
], ],
version = 1, version = 2,
exportSchema = false exportSchema = true
) )
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun groupDao(): GroupDao abstract fun groupDao(): GroupDao
abstract fun scheduleDao(): ScheduleDao 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, @PrimaryKey val id: String,
val name: String, val name: String,
val faculty: String, val faculty: String,
val autumnFirst: String, val autumnFirst: Long?,
val autumnSecond: String, val autumnSecond: Long?,
val springFirst: String, val springFirst: Long?,
val springSecond: String, val springSecond: Long?,
val lastSyncedAt: 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'") @Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
suspend fun clearSelectedGroup() 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 @Dao
interface ScheduleDao { interface ScheduleDao {
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
fun observe(groupId: String): Flow<ScheduleEntity?>
@Query("SELECT * FROM schedules WHERE groupId = :groupId") @Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
suspend fun get(groupId: String): ScheduleEntity? fun observeSchedules(groupId: String): Flow<List<ScheduleEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
suspend fun upsert(entity: ScheduleEntity) 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") @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 package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey 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( data class ScheduleEntity(
@PrimaryKey val groupId: String, @PrimaryKey(autoGenerate = true) val id: Long = 0,
val season: String, val groupId: String,
val dateRangeFirst: String, val season: Season,
val dateRangeSecond: String, val weekStart: Long,
val weeksJson: String, val weekEnd: Long,
val lastSyncedAt: 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.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Group import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.repository.GroupRepository import agzam.vyatsu.domain.repository.GroupRepository
@@ -50,16 +51,18 @@ class GroupRepositoryImpl(
id = group.id, id = group.id,
name = group.name, name = group.name,
faculty = group.faculty, faculty = group.faculty,
autumnFirst = autumn?.first ?: "", autumnFirst = autumn?.first?.let(::epochDay),
autumnSecond = autumn?.second ?: "", autumnSecond = autumn?.second?.let(::epochDay),
springFirst = spring?.first ?: "", springFirst = spring?.first?.let(::epochDay),
springSecond = spring?.second ?: "", springSecond = spring?.second?.let(::epochDay),
lastSyncedAt = now() lastSyncedAt = now()
) )
} }
groupDao.insertAll(entities) groupDao.insertAll(entities)
} }
private fun epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay()
private fun now(): Long = System.currentTimeMillis() private fun now(): Long = System.currentTimeMillis()
private fun GroupEntity.toDomain(): Group = Group( private fun GroupEntity.toDomain(): Group = Group(
@@ -9,4 +9,8 @@ class PreferencesRepositoryImpl(
override suspend fun selectedGroupId(): String? = dao.selectedGroupId() override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId) override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup() 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 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.ScheduleDao
import agzam.vyatsu.data.local.ScheduleEntity import agzam.vyatsu.data.local.ScheduleEntity
import agzam.vyatsu.data.local.GroupDao 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.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.DateRange import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.Schedule import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Season import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.model.TimeSlot
import agzam.vyatsu.domain.repository.ScheduleRepository import agzam.vyatsu.domain.repository.ScheduleRepository
import android.util.Log import android.util.Log
import kotlinx.coroutines.flow.Flow 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( class ScheduleRepositoryImpl(
private val scheduleDao: ScheduleDao, private val scheduleDao: ScheduleDao,
private val groupDao: GroupDao, private val groupDao: GroupDao
private val codec: ScheduleCodec
) : ScheduleRepository { ) : ScheduleRepository {
override fun observeSchedule(groupId: String): Flow<Schedule?> = 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? = 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? { override suspend fun sync(groupId: String): Schedule? {
Log.d(TAG, "sync(groupId=$groupId) start") Log.d(TAG, "sync(groupId=$groupId) start")
val group = groupDao.getById(groupId) val group = groupDao.getById(groupId) ?: run {
if (group == null) {
Log.w(TAG, "sync: group $groupId not found in DB") Log.w(TAG, "sync: group $groupId not found in DB")
return null return null
} }
val season = ScheduleDates.currentSeason() val season = ScheduleDates.currentSeason()
val range = dateRangeFor(group, season) val range = dateRangeFor(group, season) ?: run {
if (range == null) { Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
Log.w(TAG, "sync: no date range for season $season (group ${group.id}, " +
"autumn=[${group.autumnFirst}, ${group.autumnSecond}], " +
"spring=[${group.springFirst}, ${group.springSecond}])")
return null return null
} }
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]") Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
@@ -55,17 +67,26 @@ class ScheduleRepositoryImpl(
val days = PdfScheduleParser.parse(bytes) val days = PdfScheduleParser.parse(bytes)
Log.d(TAG, "sync: parsed ${days.size} days") Log.d(TAG, "sync: parsed ${days.size} days")
val entity = ScheduleEntity( val weekStart = ScheduleDates.asDate(range.first).toEpochDay()
groupId = groupId, val weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
season = season.name,
dateRangeFirst = range.first, val existing = scheduleDao.getSchedules(groupId)
dateRangeSecond = range.second, .firstOrNull { it.season == season && it.weekStart == weekStart }
weeksJson = codec.toJson(days), val scheduleId = if (existing != null) existing.id
lastSyncedAt = now() else scheduleDao.insertSchedule(
ScheduleEntity(
groupId = groupId,
season = season,
weekStart = weekStart,
weekEnd = weekEnd,
lastSyncedAt = now()
)
) )
scheduleDao.upsert(entity) replaceDays(scheduleId, days)
Log.d(TAG, "sync: upserted schedule for $groupId")
entity.toDomain() scheduleDao.trim(groupId, MAX_PERIODS)
getSchedule(groupId)
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e) Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
null null
@@ -73,33 +94,123 @@ class ScheduleRepositoryImpl(
} }
override suspend fun isStale(groupId: String): Boolean { override suspend fun isStale(groupId: String): Boolean {
val entity = scheduleDao.get(groupId) ?: return true val schedules = scheduleDao.getSchedules(groupId)
return now() - entity.lastSyncedAt > STALE_WINDOW_MS 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? { private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
return when (season) { return when (season) {
Season.AUTUMN -> if (group.autumnFirst.isNotEmpty() && group.autumnSecond.isNotEmpty()) { Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) {
DateRange(group.autumnFirst, group.autumnSecond) DateRange(
LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT)
)
} else null } else null
Season.SPRING -> if (group.springFirst.isNotEmpty() && group.springSecond.isNotEmpty()) { Season.SPRING -> if (group.springFirst != null && group.springSecond != null) {
DateRange(group.springFirst, group.springSecond) DateRange(
LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT)
)
} else null } else null
} }
} }
private fun now(): Long = System.currentTimeMillis() private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun ScheduleEntity.toDomain(): Schedule = Schedule( private fun now(): Long = System.currentTimeMillis()
groupId = groupId,
season = Season.valueOf(season),
days = codec.fromJson(weeksJson)
)
companion object { companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
private const val MAX_PERIODS = 3
private const val TAG = "ScheduleRepo" private const val TAG = "ScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
} }
} }
@@ -1,7 +1,6 @@
package agzam.vyatsu.di package agzam.vyatsu.di
import agzam.vyatsu.data.local.AppDatabase import agzam.vyatsu.data.local.AppDatabase
import agzam.vyatsu.data.local.ScheduleCodec
import agzam.vyatsu.data.remote.GroupsParser import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.repository.GroupRepositoryImpl import agzam.vyatsu.data.repository.GroupRepositoryImpl
@@ -12,18 +11,15 @@ import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository import agzam.vyatsu.domain.repository.ScheduleRepository
import android.content.Context import android.content.Context
import androidx.room.Room import androidx.room.Room
import com.squareup.moshi.Moshi
class AppContainer(context: Context) { class AppContainer(context: Context) {
private val moshi: Moshi = Moshi.Builder().build()
private val database: AppDatabase = Room.databaseBuilder( private val database: AppDatabase = Room.databaseBuilder(
context.applicationContext, context.applicationContext,
AppDatabase::class.java, AppDatabase::class.java,
"vyatsu_schedule.db" "vyatsu_schedule.db"
).build() )
.fallbackToDestructiveMigration(dropAllTables = true)
private val codec: ScheduleCodec = ScheduleCodec(moshi) .build()
val groupRepository: GroupRepository by lazy { val groupRepository: GroupRepository by lazy {
GroupRepositoryImpl( GroupRepositoryImpl(
@@ -35,7 +31,6 @@ class AppContainer(context: Context) {
ScheduleRepositoryImpl( ScheduleRepositoryImpl(
scheduleDao = database.scheduleDao(), scheduleDao = database.scheduleDao(),
groupDao = database.groupDao(), groupDao = database.groupDao(),
codec = codec
) )
} }
@@ -1,17 +1,21 @@
package agzam.vyatsu.domain.model package agzam.vyatsu.domain.model
/** import java.time.LocalDate
* Расписание группы, как правило содержит 2 недели
* @author Agzam data class SchedulePeriod(
*/ val weekStart: LocalDate,
val weekEnd: LocalDate,
val season: Season
)
data class Schedule( data class Schedule(
val groupId: String, val groupId: String,
val season: Season, val periods: List<SchedulePeriod>,
/** Может содержать более 7 дней */
val days: List<Day> 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 selectedGroupId(): String?
suspend fun setSelectedGroup(groupId: String) suspend fun setSelectedGroup(groupId: String)
suspend fun clearSelectedGroup() 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 package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.Schedule import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.model.SchedulePeriod
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface ScheduleRepository { interface ScheduleRepository {
@@ -8,5 +9,6 @@ interface ScheduleRepository {
suspend fun getSchedule(groupId: String): Schedule? suspend fun getSchedule(groupId: String): Schedule?
suspend fun sync(groupId: String): Schedule? suspend fun sync(groupId: String): Schedule?
suspend fun isStale(groupId: String): Boolean 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 package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -35,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -55,7 +58,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}, },
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top) windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
) )
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
when { when {
state.isSyncing && state.schedule == null -> { state.isSyncing && state.schedule == null -> {
Row( Row(
@@ -83,23 +86,48 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
else -> { else -> {
val schedule = state.schedule!! 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) { if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge) Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else { } else {
val isToday = state.selectedIndex == state.todayIndex val isTodayDay = day.date == state.todayDate
val dayLabel = if (isToday) "Сегодня " else "" val dayLabel = if (isTodayDay) "Сегодня " else ""
Text( Text(
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}", "$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium 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)) HorizontalDivider(Modifier.padding(vertical = 8.dp))
DayChips( DayChips(
days = schedule.days, days = weekDays,
selectedIndex = state.selectedIndex, selectedDate = state.dayDate,
todayIndex = state.todayIndex, todayDate = state.todayDate,
onSelect = viewModel::selectDay onSelect = viewModel::selectDay
) )
@@ -125,19 +153,56 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
} }
@Composable @Composable
private fun DayChips( private fun WeekChips(
days: List<Day>, periods: List<SchedulePeriod>,
selectedIndex: Int, selectedWeekStart: LocalDate?,
todayIndex: Int, todayWeekStart: LocalDate?,
onSelect: (Int) -> Unit onSelect: (LocalDate) -> Unit
) { ) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
itemsIndexed(days) { index, day -> items(periods, key = { it.weekStart.toEpochDay() }) { period ->
val selected = selectedIndex == index val selected = selectedWeekStart == period.weekStart
val isTodayChip = todayIndex == index val isTodayWeek = todayWeekStart == period.weekStart
FilterChip( FilterChip(
selected = selected, 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 = { label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
@@ -172,6 +237,11 @@ private fun DayChips(
} }
} }
private fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@Composable @Composable
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) { private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
@@ -190,8 +260,12 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
Text( Text(
listOf(lesson.type.label, lesson.subgroup, lesson.teacher, lesson.room) listOfNotNull(
.filterNotNull() lesson.type.label,
lesson.subgroup,
lesson.teacher,
lesson.room
)
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.joinToString(" · "), .joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -1,7 +1,6 @@
package agzam.vyatsu.ui.schedule package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.ScheduleDates import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Schedule import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.repository.PreferencesRepository import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository import agzam.vyatsu.domain.repository.ScheduleRepository
@@ -11,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate
class ScheduleViewModel( class ScheduleViewModel(
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: ScheduleRepository,
@@ -37,32 +37,103 @@ class ScheduleViewModel(
currentGroupId = groupId currentGroupId = groupId
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId) 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 { launch {
scheduleRepository.observeSchedule(groupId).collect { schedule -> scheduleRepository.observeSchedule(groupId).collect { schedule ->
currentGroupId?.let { currentGroupId?.let { applySchedule(schedule) }
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
)
}
} }
} }
launch { launch {
_uiState.value = _uiState.value.copy(isSyncing = true) syncIfMissingToday(groupId)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isSyncing = false)
} }
} }
} }
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() { fun switchGroup() {
viewModelScope.launch { viewModelScope.launch {
currentGroupId = null currentGroupId = null
@@ -76,30 +147,25 @@ class ScheduleViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true) _uiState.value = _uiState.value.copy(isRefreshing = true)
scheduleRepository.sync(groupId) scheduleRepository.sync(groupId)
val current = _uiState.value _uiState.value = _uiState.value.copy(isRefreshing = false)
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
)
} }
} }
fun selectDay(index: Int) { fun clearAll() {
val days = _uiState.value.schedule?.days.orEmpty() val groupId = currentGroupId ?: return
if (index !in days.indices) return viewModelScope.launch {
_uiState.value = _uiState.value.copy(selectedIndex = index) scheduleRepository.clearAll(groupId)
}
} }
private fun todayIndex(days: List<Day>): Int { fun clearSelectedPeriod() {
if (days.isEmpty()) return 0 val state = _uiState.value
val today = ScheduleDates.moscowToday() val schedule = state.schedule ?: return
val exact = days.indexOfFirst { it.date == today } val weekStart = state.weekStart ?: return
if (exact >= 0) return exact val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
val next = days.indexOfFirst { !it.date.isBefore(today) } viewModelScope.launch {
return if (next >= 0) next else days.lastIndex scheduleRepository.clearPeriod(schedule.groupId, period)
}
} }
} }
@@ -108,6 +174,7 @@ data class ScheduleUiState(
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val schedule: Schedule? = null, val schedule: Schedule? = null,
val todayIndex: Int = 0, val weekStart: LocalDate? = null,
val selectedIndex: Int = 0 val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null
) )