Compare commits

..
8 Commits
Author SHA1 Message Date
Agzamus 96161d6844 Merge branch 'database-refactor' 2026-09-03 20:56:20 +03:00
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 84ae97b906 feat: group display on schedule screen 2026-09-03 19:59:21 +03:00
Agzamus 2069ec811a ci: removed "Upload APK as artifact" action step 2026-09-03 19:47:06 +03:00
Agzamus ebe1c3c4a3 refactor: database multi-period schedule storage 2026-09-03 19:41:26 +03:00
26 changed files with 891 additions and 219 deletions
-6
View File
@@ -112,9 +112,3 @@ jobs:
files: ${{ steps.apk.outputs.apk_path }}
token: ${{ secrets.GITEA_TOKEN }}
server_url: ${{ gitea.server_url }}
- name: Upload APK as artifact
uses: actions/upload-artifact@v4
with:
name: vyatsu-schedule-${{ steps.version.outputs.version_name }}
path: ${{ steps.apk.outputs.apk_path }}
+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')"
]
}
}
+1 -1
View File
@@ -41,5 +41,5 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
}
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository) }
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
@@ -2,16 +2,20 @@ package agzam.vyatsu.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(
entities = [
GroupEntity::class,
ScheduleEntity::class,
DayEntity::class,
LessonEntity::class,
PreferenceEntity::class
],
version = 1,
exportSchema = false
version = 2,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun groupDao(): GroupDao
abstract fun scheduleDao(): ScheduleDao
@@ -0,0 +1,29 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.TypeConverter
import java.time.LocalDate
import java.time.LocalTime
class Converters {
@TypeConverter
fun localDateToEpochDay(date: LocalDate?): Long? = date?.toEpochDay()
@TypeConverter
fun epochDayToLocalDate(epochDay: Long?): LocalDate? = epochDay?.let(LocalDate::ofEpochDay)
@TypeConverter
fun localTimeToMinutes(time: LocalTime?): Int? = time?.let { it.hour * 60 + it.minute }
@TypeConverter
fun minutesToLocalTime(minutes: Int?): LocalTime? =
minutes?.let { LocalTime.of(it / 60, it % 60) }
@TypeConverter
fun lessonTypeToString(type: LessonType): String = type.name
@TypeConverter
fun stringToLessonType(value: String): LessonType =
runCatching { LessonType.valueOf(value) }.getOrDefault(LessonType.OTHER)
}
@@ -1,23 +0,0 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Day
import com.squareup.moshi.JsonClass
import java.time.LocalDate
@JsonClass(generateAdapter = true)
data class DayDto(
val date: String,
val slots: List<SlotDto> = emptyList()
) {
fun toDomain(): Day = Day(
date = LocalDate.parse(date),
intervals = slots.map { it.toDomain() }
)
companion object {
fun fromDomain(d: Day): DayDto = DayDto(
date = d.date.toString(),
slots = d.intervals.map { SlotDto.fromDomain(it) }
)
}
}
@@ -0,0 +1,27 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "days",
foreignKeys = [
ForeignKey(
entity = ScheduleEntity::class,
parentColumns = ["id"],
childColumns = ["scheduleId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("scheduleId"),
Index("date")
]
)
data class DayEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val scheduleId: Long,
val date: Long
)
@@ -8,9 +8,9 @@ data class GroupEntity(
@PrimaryKey val id: String,
val name: String,
val faculty: String,
val autumnFirst: String,
val autumnSecond: String,
val springFirst: String,
val springSecond: String,
val autumnFirst: Long?,
val autumnSecond: Long?,
val springFirst: Long?,
val springSecond: Long?,
val lastSyncedAt: Long
)
@@ -1,38 +0,0 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class LessonDto(
val raw: String = "",
val room: String? = null,
val group: String? = null,
val subgroup: String? = null,
val subject: String? = null,
val type: String = "",
val teacher: String? = null
) {
fun toDomain(): Lesson = Lesson(
raw = raw,
room = room?.takeIf { it.isNotBlank() },
group = group?.takeIf { it.isNotBlank() },
subgroup = subgroup?.takeIf { it.isNotBlank() },
subject = subject?.takeIf { it.isNotBlank() },
type = runCatching { LessonType.valueOf(type) }.getOrDefault(LessonType.OTHER),
teacher = teacher?.takeIf { it.isNotBlank() }
)
companion object {
fun fromDomain(l: Lesson): LessonDto = LessonDto(
raw = l.raw,
room = l.room,
group = l.group,
subgroup = l.subgroup,
subject = l.subject,
type = l.type.name,
teacher = l.teacher
)
}
}
@@ -0,0 +1,41 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "lessons",
foreignKeys = [
ForeignKey(
entity = DayEntity::class,
parentColumns = ["id"],
childColumns = ["dayId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("dayId"),
Index("date"),
Index("subject"),
Index("room")
]
)
data class LessonEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val dayId: Long,
val date: Long,
val slotIndex: Int,
val start: Int,
val end: Int,
val lessonIndex: Int,
val raw: String,
val room: String?,
val groupName: String?,
val subgroup: String?,
val subject: String?,
val type: LessonType,
val teacher: String?
)
@@ -15,4 +15,16 @@ interface PreferencesDao {
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
suspend fun clearSelectedGroup()
@Query("SELECT value FROM preferences WHERE key = 'selectedWeekStart'")
suspend fun selectedWeekStart(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedWeekStart', :weekStart)")
suspend fun setSelectedWeekStart(weekStart: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedDayDate'")
suspend fun selectedDayDate(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
suspend fun setSelectedDayDate(date: String)
}
@@ -1,25 +0,0 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Day
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
class ScheduleCodec(moshi: Moshi) {
private val type: java.lang.reflect.Type =
Types.newParameterizedType(List::class.java, DayDto::class.java)
private val adapter = moshi.adapter<List<DayDto>>(type)
fun toJson(days: List<Day>): String {
val dto = days.map { DayDto.fromDomain(it) }
return adapter.toJson(dto)
}
fun fromJson(json: String): List<Day> {
return try {
val dto = adapter.fromJson(json) ?: return emptyList()
dto.map { it.toDomain() }
} catch (t: Throwable) {
emptyList()
}
}
}
@@ -8,15 +8,68 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface ScheduleDao {
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
fun observe(groupId: String): Flow<ScheduleEntity?>
@Query("SELECT * FROM schedules WHERE groupId = :groupId")
suspend fun get(groupId: String): ScheduleEntity?
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
fun observeSchedules(groupId: String): Flow<List<ScheduleEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ScheduleEntity)
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
suspend fun getSchedules(groupId: String): List<ScheduleEntity>
@Query("DELETE FROM days WHERE scheduleId = :scheduleId")
suspend fun deleteDaysForSchedule(scheduleId: Long)
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertSchedule(schedule: ScheduleEntity): Long
@Query("DELETE FROM schedules WHERE groupId = :groupId AND season = :season AND weekStart = :weekStart")
suspend fun deleteSchedule(groupId: String, season: String, weekStart: Long)
@Query("DELETE FROM schedules WHERE groupId = :groupId")
suspend fun delete(groupId: String)
suspend fun deleteAll(groupId: String)
@Query(
"""
DELETE FROM schedules
WHERE groupId = :groupId
AND id NOT IN (
SELECT id FROM schedules
WHERE groupId = :groupId
ORDER BY weekStart DESC
LIMIT :keep
)
"""
)
suspend fun trim(groupId: String, keep: Int)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDays(days: List<DayEntity>): List<Long>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLessons(lessons: List<LessonEntity>)
@Query(
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY days.date"
)
suspend fun getDaysForGroup(groupId: String): List<DayEntity>
@Query(
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
)
suspend fun getLessonsForGroup(groupId: String): List<LessonEntity>
@Query(
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY days.date"
)
fun observeDaysForGroup(groupId: String): Flow<List<DayEntity>>
@Query(
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
)
fun observeLessonsForGroup(groupId: String): Flow<List<LessonEntity>>
}
@@ -1,14 +1,31 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "schedules")
@Entity(
tableName = "schedules",
foreignKeys = [
ForeignKey(
entity = GroupEntity::class,
parentColumns = ["id"],
childColumns = ["groupId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("groupId"),
Index(value = ["groupId", "season", "weekStart"], unique = true)
]
)
data class ScheduleEntity(
@PrimaryKey val groupId: String,
val season: String,
val dateRangeFirst: String,
val dateRangeSecond: String,
val weeksJson: String,
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val groupId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long,
val lastSyncedAt: Long
)
@@ -1,26 +0,0 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.TimeSlot
import com.squareup.moshi.JsonClass
import java.time.LocalTime
@JsonClass(generateAdapter = true)
data class SlotDto(
val start: String,
val end: String,
val lessons: List<LessonDto> = emptyList()
) {
fun toDomain(): TimeSlot = TimeSlot(
start = LocalTime.parse(start),
end = LocalTime.parse(end),
lessons = lessons.map { it.toDomain() }
)
companion object {
fun fromDomain(s: TimeSlot): SlotDto = SlotDto(
start = s.start.toString(),
end = s.end.toString(),
lessons = s.lessons.map { LessonDto.fromDomain(it) }
)
}
}
@@ -5,6 +5,7 @@ import agzam.vyatsu.data.local.GroupEntity
import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.repository.GroupRepository
@@ -22,6 +23,9 @@ class GroupRepositoryImpl(
override suspend fun getGroups(): List<Group> =
groupDao.observeAll().first().map { it.toDomain() }
override suspend fun getGroup(id: String): Group? =
groupDao.getById(id)?.toDomain()
override suspend fun getFaculties(): List<String> = groupDao.getFaculties()
override suspend fun refreshIfStale(): Boolean {
@@ -50,16 +54,18 @@ class GroupRepositoryImpl(
id = group.id,
name = group.name,
faculty = group.faculty,
autumnFirst = autumn?.first ?: "",
autumnSecond = autumn?.second ?: "",
springFirst = spring?.first ?: "",
springSecond = spring?.second ?: "",
autumnFirst = autumn?.first?.let(::epochDay),
autumnSecond = autumn?.second?.let(::epochDay),
springFirst = spring?.first?.let(::epochDay),
springSecond = spring?.second?.let(::epochDay),
lastSyncedAt = now()
)
}
groupDao.insertAll(entities)
}
private fun epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay()
private fun now(): Long = System.currentTimeMillis()
private fun GroupEntity.toDomain(): Group = Group(
@@ -9,4 +9,8 @@ class PreferencesRepositoryImpl(
override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup()
override suspend fun selectedWeekStart(): String? = dao.selectedWeekStart()
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
}
@@ -1,6 +1,7 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.ScheduleCodec
import agzam.vyatsu.data.local.DayEntity
import agzam.vyatsu.data.local.LessonEntity
import agzam.vyatsu.data.local.ScheduleDao
import agzam.vyatsu.data.local.ScheduleEntity
import agzam.vyatsu.data.local.GroupDao
@@ -9,39 +10,50 @@ import agzam.vyatsu.data.remote.pdf.PdfScheduleParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.model.TimeSlot
import agzam.vyatsu.domain.repository.ScheduleRepository
import android.util.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import java.time.LocalDate
import java.time.LocalTime
class ScheduleRepositoryImpl(
private val scheduleDao: ScheduleDao,
private val groupDao: GroupDao,
private val codec: ScheduleCodec
private val groupDao: GroupDao
) : ScheduleRepository {
override fun observeSchedule(groupId: String): Flow<Schedule?> =
scheduleDao.observe(groupId).map { it?.toDomain() }
combine(
scheduleDao.observeSchedules(groupId),
scheduleDao.observeDaysForGroup(groupId),
scheduleDao.observeLessonsForGroup(groupId)
) { schedules, days, lessons ->
buildSchedule(schedules, days, lessons)
}
override suspend fun getSchedule(groupId: String): Schedule? =
scheduleDao.get(groupId)?.toDomain()
buildSchedule(
scheduleDao.getSchedules(groupId),
scheduleDao.getDaysForGroup(groupId),
scheduleDao.getLessonsForGroup(groupId)
)
override suspend fun sync(groupId: String): Schedule? {
Log.d(TAG, "sync(groupId=$groupId) start")
val group = groupDao.getById(groupId)
if (group == null) {
val group = groupDao.getById(groupId) ?: run {
Log.w(TAG, "sync: group $groupId not found in DB")
return null
}
val season = ScheduleDates.currentSeason()
val range = dateRangeFor(group, season)
if (range == null) {
Log.w(TAG, "sync: no date range for season $season (group ${group.id}, " +
"autumn=[${group.autumnFirst}, ${group.autumnSecond}], " +
"spring=[${group.springFirst}, ${group.springSecond}])")
val range = dateRangeFor(group, season) ?: run {
Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
return null
}
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
@@ -55,17 +67,26 @@ class ScheduleRepositoryImpl(
val days = PdfScheduleParser.parse(bytes)
Log.d(TAG, "sync: parsed ${days.size} days")
val entity = ScheduleEntity(
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,123 @@ class ScheduleRepositoryImpl(
}
override suspend fun isStale(groupId: String): Boolean {
val entity = scheduleDao.get(groupId) ?: return true
return now() - entity.lastSyncedAt > STALE_WINDOW_MS
val schedules = scheduleDao.getSchedules(groupId)
val latest = schedules.maxByOrNull { it.weekStart } ?: return true
return now() - latest.lastSyncedAt > STALE_WINDOW_MS
}
override suspend fun clear(groupId: String) = scheduleDao.delete(groupId)
override suspend fun clearPeriod(groupId: String, period: SchedulePeriod) {
scheduleDao.deleteSchedule(
groupId = groupId,
season = period.season.name,
weekStart = period.weekStart.toEpochDay()
)
}
override suspend fun clearAll(groupId: String) = scheduleDao.deleteAll(groupId)
private suspend fun replaceDays(scheduleId: Long, days: List<Day>) {
scheduleDao.deleteDaysForSchedule(scheduleId)
val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) }
val dayIds = scheduleDao.insertDays(dayEntities)
val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) -> LocalDate.ofEpochDay(day.date) to id }
val lessons = days.flatMap { day ->
day.intervals.flatMapIndexed { slotIndex, slot ->
slot.lessons.mapIndexed { lessonIndex, lesson ->
LessonEntity(
dayId = dayIdByDate.getValue(day.date),
date = day.date.toEpochDay(),
slotIndex = slotIndex,
start = slot.start.toMinutesOfDay(),
end = slot.end.toMinutesOfDay(),
lessonIndex = lessonIndex,
raw = lesson.raw,
room = lesson.room,
groupName = lesson.group,
subgroup = lesson.subgroup,
subject = lesson.subject,
type = lesson.type,
teacher = lesson.teacher
)
}
}
}
scheduleDao.insertLessons(lessons)
}
private fun buildSchedule(
schedules: List<ScheduleEntity>,
dayEntities: List<DayEntity>,
lessonEntities: List<LessonEntity>
): Schedule? {
if (schedules.isEmpty()) return null
val groupId = schedules.first().groupId
val periods = schedules.sortedBy { it.weekStart }.map {
SchedulePeriod(
weekStart = LocalDate.ofEpochDay(it.weekStart),
weekEnd = LocalDate.ofEpochDay(it.weekEnd),
season = it.season
)
}
val lessonsByDay = lessonEntities.groupBy { it.dayId }
val days = dayEntities
.sortedBy { it.date }
.map { day ->
val lessons = lessonsByDay[day.id].orEmpty()
.groupBy { it.slotIndex }
.toSortedMap()
.map { (_, slotLessons) ->
TimeSlot(
start = LocalTime.of(slotLessons.first().start / 60, slotLessons.first().start % 60),
end = LocalTime.of(slotLessons.first().end / 60, slotLessons.first().end % 60),
lessons = slotLessons
.sortedBy { it.lessonIndex }
.map { it.toDomain() }
)
}
Day(date = LocalDate.ofEpochDay(day.date), intervals = lessons)
}
return Schedule(groupId = groupId, periods = periods, days = days)
}
private fun LessonEntity.toDomain(): Lesson = Lesson(
raw = raw,
room = room,
group = groupName,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
return when (season) {
Season.AUTUMN -> if (group.autumnFirst.isNotEmpty() && group.autumnSecond.isNotEmpty()) {
DateRange(group.autumnFirst, group.autumnSecond)
Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) {
DateRange(
LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT)
)
} else null
Season.SPRING -> if (group.springFirst.isNotEmpty() && group.springSecond.isNotEmpty()) {
DateRange(group.springFirst, group.springSecond)
Season.SPRING -> if (group.springFirst != null && group.springSecond != null) {
DateRange(
LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT)
)
} else null
}
}
private fun 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")
}
}
@@ -1,7 +1,6 @@
package agzam.vyatsu.di
import agzam.vyatsu.data.local.AppDatabase
import agzam.vyatsu.data.local.ScheduleCodec
import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.repository.GroupRepositoryImpl
@@ -12,18 +11,15 @@ import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository
import android.content.Context
import androidx.room.Room
import com.squareup.moshi.Moshi
class AppContainer(context: Context) {
private val moshi: Moshi = Moshi.Builder().build()
private val database: AppDatabase = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"vyatsu_schedule.db"
).build()
private val codec: ScheduleCodec = ScheduleCodec(moshi)
)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
val groupRepository: GroupRepository by lazy {
GroupRepositoryImpl(
@@ -35,7 +31,6 @@ class AppContainer(context: Context) {
ScheduleRepositoryImpl(
scheduleDao = database.scheduleDao(),
groupDao = database.groupDao(),
codec = codec
)
}
@@ -1,17 +1,21 @@
package agzam.vyatsu.domain.model
/**
* Расписание группы, как правило содержит 2 недели
* @author Agzam
*/
import java.time.LocalDate
data class SchedulePeriod(
val weekStart: LocalDate,
val weekEnd: LocalDate,
val season: Season
)
data class Schedule(
val groupId: String,
val season: Season,
/** Может содержать более 7 дней */
val periods: List<SchedulePeriod>,
val days: List<Day>
) {
fun periodOf(date: LocalDate): SchedulePeriod? =
periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) }
fun daysOf(period: SchedulePeriod): List<Day> =
days.filter { !it.date.isBefore(period.weekStart) && !it.date.isAfter(period.weekEnd) }
}
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
interface GroupRepository {
fun observeGroups(): Flow<List<Group>>
suspend fun getGroups(): List<Group>
suspend fun getGroup(id: String): Group?
suspend fun getFaculties(): List<String>
suspend fun refreshIfStale(): Boolean
suspend fun forceRefresh(): Boolean
@@ -4,4 +4,8 @@ interface PreferencesRepository {
suspend fun selectedGroupId(): String?
suspend fun setSelectedGroup(groupId: String)
suspend fun clearSelectedGroup()
suspend fun selectedWeekStart(): String?
suspend fun setSelectedWeekStart(weekStart: String)
suspend fun selectedDayDate(): String?
suspend fun setSelectedDayDate(date: String)
}
@@ -1,6 +1,7 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.model.SchedulePeriod
import kotlinx.coroutines.flow.Flow
interface ScheduleRepository {
@@ -8,5 +9,6 @@ interface ScheduleRepository {
suspend fun getSchedule(groupId: String): Schedule?
suspend fun sync(groupId: String): Schedule?
suspend fun isStale(groupId: String): Boolean
suspend fun clear(groupId: String)
suspend fun clearPeriod(groupId: String, period: SchedulePeriod)
suspend fun clearAll(groupId: String)
}
@@ -1,6 +1,7 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -35,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@@ -44,7 +47,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Расписание") },
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
actions = {
TextButton(onClick = { onSwitchGroup() }) {
Text("Сменить группу")
@@ -53,9 +56,9 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
},
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
windowInsets = WindowInsets(0.dp)
)
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
when {
state.isSyncing && state.schedule == null -> {
Row(
@@ -83,23 +86,48 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
else -> {
val schedule = state.schedule!!
val day = schedule.days.getOrNull(state.selectedIndex)
val weekDays = state.weekStart
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
?.let { schedule.daysOf(it) }
.orEmpty()
val day = schedule.days.firstOrNull { it.date == state.dayDate }
if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
val isToday = state.selectedIndex == state.todayIndex
val dayLabel = if (isToday) "Сегодня " else ""
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
Text(
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(onClick = { viewModel.clearSelectedPeriod() }) {
Text("Очистить эту неделю")
}
TextButton(onClick = { viewModel.clearAll() }) {
Text("Очистить всё")
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
WeekChips(
periods = schedule.periods,
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
onSelect = viewModel::selectWeek
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
DayChips(
days = schedule.days,
selectedIndex = state.selectedIndex,
todayIndex = state.todayIndex,
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
onSelect = viewModel::selectDay
)
@@ -125,19 +153,56 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
@Composable
private fun DayChips(
days: List<Day>,
selectedIndex: Int,
todayIndex: Int,
onSelect: (Int) -> Unit
private fun WeekChips(
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
itemsIndexed(days) { index, day ->
val selected = selectedIndex == index
val isTodayChip = todayIndex == index
items(periods, key = { it.weekStart.toEpochDay() }) { period ->
val selected = selectedWeekStart == period.weekStart
val isTodayWeek = todayWeekStart == period.weekStart
FilterChip(
selected = selected,
onClick = { onSelect(index) },
onClick = { onSelect(period.weekStart) },
label = {
Text(
weekLabel(period),
style = MaterialTheme.typography.labelLarge
)
},
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayWeek && !selected) {
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
)
} else {
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
},
shape = RoundedCornerShape(8.dp)
)
}
}
}
@Composable
private fun DayChips(
days: List<Day>,
selectedDate: LocalDate?,
todayDate: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date
FilterChip(
selected = selected,
onClick = { onSelect(day.date) },
label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
@@ -172,6 +237,11 @@ private fun DayChips(
}
}
private fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@Composable
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
Column(Modifier.fillMaxWidth()) {
@@ -190,8 +260,12 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
style = MaterialTheme.typography.bodyLarge
)
Text(
listOf(lesson.type.label, lesson.subgroup, lesson.teacher, lesson.room)
.filterNotNull()
listOfNotNull(
lesson.type.label,
lesson.subgroup,
lesson.teacher,
lesson.room
)
.filter { it.isNotBlank() }
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@@ -215,4 +289,4 @@ private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
@@ -3,6 +3,7 @@ package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.repository.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository
import androidx.lifecycle.ViewModel
@@ -14,7 +15,8 @@ import kotlinx.coroutines.launch
class ScheduleViewModel(
private val scheduleRepository: ScheduleRepository,
private val preferencesRepository: PreferencesRepository
private val preferencesRepository: PreferencesRepository,
private val groupRepository: GroupRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ScheduleUiState())
@@ -37,7 +39,8 @@ class ScheduleViewModel(
currentGroupId = groupId
viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId)
_uiState.value = _uiState.value.copy(noGroup = false)
val groupName = groupRepository.getGroup(groupId)?.name
_uiState.value = _uiState.value.copy(noGroup = false, groupName = groupName)
launch {
scheduleRepository.observeSchedule(groupId).collect { schedule ->
@@ -93,6 +96,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()
@@ -107,6 +127,7 @@ data class ScheduleUiState(
val noGroup: Boolean = false,
val isRefreshing: Boolean = false,
val isSyncing: Boolean = false,
val groupName: String? = null,
val schedule: Schedule? = null,
val todayIndex: Int = 0,
val selectedIndex: Int = 0