fix(teacher): register migration, containment period queries, concurrency guards

- Register AppDatabase.MIGRATION_3_4 so v3 databases upgrade without data loss
- Query teacher schedule periods by containment (weekStart<=x AND weekEnd>=x)
  instead of exact weekStart, matching 2-week catalog windows
- Clip buildTeacherSchedule days to the requested week range
- Add weeksFor() to enumerate weeks of a catalog window for the UI selector
- Serialize syncPeriod body with a Mutex to protect the unique index
- Rethrow CancellationException before generic catch; version loadRevision
  checks so stale observers/loads are cancelled
- Sync job tracking in PickerViewModel; keep loading true while empty
- Drop dead selectTeacher helper
This commit is contained in:
2026-09-19 23:32:09 +03:00
parent 39e4f5a04f
commit 1d55e84e96
7 changed files with 205 additions and 103 deletions
@@ -40,14 +40,16 @@ interface TeacherScheduleDao {
@Query( @Query(
"SELECT teacher_days.* FROM teacher_days INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " + "SELECT teacher_days.* FROM teacher_days INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart = :weekStart ORDER BY teacher_days.date" "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher_days.date"
) )
fun observeDaysForPeriod(season: String, weekStart: Long): Flow<List<TeacherDayEntity>> fun observeDaysForPeriod(season: String, weekStart: Long): Flow<List<TeacherDayEntity>>
@Query( @Query(
"SELECT teacher_lessons.* FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " + "SELECT teacher_lessons.* FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " + "INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart = :weekStart " + "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart " +
"ORDER BY teacher_lessons.date, teacher_lessons.slotIndex, teacher_lessons.lessonIndex" "ORDER BY teacher_lessons.date, teacher_lessons.slotIndex, teacher_lessons.lessonIndex"
) )
fun observeLessonsForPeriod(season: String, weekStart: Long): Flow<List<TeacherLessonEntity>> fun observeLessonsForPeriod(season: String, weekStart: Long): Flow<List<TeacherLessonEntity>>
@@ -55,12 +57,14 @@ interface TeacherScheduleDao {
@Query( @Query(
"SELECT DISTINCT teacher FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " + "SELECT DISTINCT teacher FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " + "INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart = :weekStart ORDER BY teacher" "WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher"
) )
fun observeDistinctTeachers(season: String, weekStart: Long): Flow<List<String>> fun observeDistinctTeachers(season: String, weekStart: Long): Flow<List<String>>
@Query( @Query(
"SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season AND weekStart = :weekStart)" "SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season " +
"AND weekStart <= :weekStart AND weekEnd >= :weekStart)"
) )
suspend fun hasSchedulesForPeriod(season: String, weekStart: Long): Boolean suspend fun hasSchedulesForPeriod(season: String, weekStart: Long): Boolean
} }
@@ -26,17 +26,24 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
class TeacherScheduleRepositoryImpl( class TeacherScheduleRepositoryImpl(
private val scheduleDao: TeacherScheduleDao, private val scheduleDao: TeacherScheduleDao,
private val teacherDao: TeacherDao private val teacherDao: TeacherDao
) : TeacherScheduleRepository { ) : TeacherScheduleRepository {
private val syncMutex = Mutex()
override fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> = override fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> =
combine( combine(
scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()), scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()),
@@ -50,12 +57,15 @@ class TeacherScheduleRepositoryImpl(
.map { names -> names.map { name -> Teacher(name = name) } } .map { names -> names.map { name -> Teacher(name = name) } }
override suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress> = flow { override suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress> = flow {
// Unique index (departmentId, season, weekStart) breaks on interleaved
// delete+insert from concurrent syncPeriod() callers.
syncMutex.withLock {
val targets = withContext(Dispatchers.IO) { val targets = withContext(Dispatchers.IO) {
teacherDao.getPeriodsForDate(date.toEpochDay()) teacherDao.getPeriodsForDate(date.toEpochDay())
} }
if (targets.isEmpty()) { if (targets.isEmpty()) {
emit(BulkSyncProgress(0, 0, 0)) emit(BulkSyncProgress(0, 0, 0))
return@flow return@withLock
} }
targets.map { it.season to it.weekStart } targets.map { it.season to it.weekStart }
@@ -75,6 +85,8 @@ class TeacherScheduleRepositoryImpl(
try { try {
withContext(Dispatchers.IO) { syncDepartment(target) } withContext(Dispatchers.IO) { syncDepartment(target) }
true true
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e( Log.e(
TAG, TAG,
@@ -99,12 +111,37 @@ class TeacherScheduleRepositoryImpl(
withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) } withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) }
} }
}
override suspend fun hasData(period: SchedulePeriod): Boolean = override suspend fun hasData(period: SchedulePeriod): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay()) scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay())
} }
override suspend fun weeksFor(date: LocalDate): List<SchedulePeriod> =
withContext(Dispatchers.IO) {
teacherDao.getPeriodsForDate(date.toEpochDay())
.distinctBy { it.season to (it.weekStart to it.weekEnd) }
.flatMap { window ->
val windowStart = LocalDate.ofEpochDay(window.weekStart)
val windowEnd = LocalDate.ofEpochDay(window.weekEnd)
generateSequence(
windowStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
) { it.plusWeeks(1) }
.takeWhile { it <= windowEnd }
.map { monday ->
val weekEnd = monday.plusDays(6)
SchedulePeriod(
weekStart = monday,
weekEnd = if (weekEnd.isAfter(windowEnd)) windowEnd else weekEnd,
season = window.season
)
}
}
.distinctBy { it.season to it.weekStart }
.sortedBy { it.weekStart }
}
override suspend fun clearAll() = withContext(Dispatchers.IO) { override suspend fun clearAll() = withContext(Dispatchers.IO) {
scheduleDao.deleteAll() scheduleDao.deleteAll()
} }
@@ -182,9 +219,13 @@ class TeacherScheduleRepositoryImpl(
dayEntities: List<TeacherDayEntity>, dayEntities: List<TeacherDayEntity>,
lessonEntities: List<TeacherLessonEntity> lessonEntities: List<TeacherLessonEntity>
): TeacherSchedule? { ): TeacherSchedule? {
if (dayEntities.isEmpty()) return null val weekDays = dayEntities
.filter {
it.date >= period.weekStart.toEpochDay() && it.date <= period.weekEnd.toEpochDay()
}
if (weekDays.isEmpty()) return null
val lessonsByDate = lessonEntities.groupBy { it.date } val lessonsByDate = lessonEntities.groupBy { it.date }
val days = dayEntities val days = weekDays
.sortedBy { it.date } .sortedBy { it.date }
.distinctBy { it.date } .distinctBy { it.date }
.map { day -> .map { day ->
@@ -23,6 +23,7 @@ class AppContainer(context: Context) {
AppDatabase::class.java, AppDatabase::class.java,
"vyatsu_schedule.db" "vyatsu_schedule.db"
) )
.addMigrations(AppDatabase.MIGRATION_3_4)
.fallbackToDestructiveMigration(dropAllTables = true) .fallbackToDestructiveMigration(dropAllTables = true)
.build() .build()
@@ -12,5 +12,6 @@ interface TeacherScheduleRepository {
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress> suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress>
suspend fun hasData(period: SchedulePeriod): Boolean suspend fun hasData(period: SchedulePeriod): Boolean
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
suspend fun clearAll() suspend fun clearAll()
} }
@@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.temporal.TemporalAdjusters import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -45,6 +46,7 @@ class PickerViewModel(
val uiState: StateFlow<PickerUiState> = _uiState.asStateFlow() val uiState: StateFlow<PickerUiState> = _uiState.asStateFlow()
private var teachersJob: kotlinx.coroutines.Job? = null private var teachersJob: kotlinx.coroutines.Job? = null
private var syncJob: kotlinx.coroutines.Job? = null
init { init {
groupsLoad() groupsLoad()
@@ -139,11 +141,20 @@ class PickerViewModel(
_uiState.value = _uiState.value.copy(syncProgress = null, teachersLoading = true) _uiState.value = _uiState.value.copy(syncProgress = null, teachersLoading = true)
viewModelScope.launch { syncJob?.cancel()
runCatching { syncJob = viewModelScope.launch {
try {
teacherScheduleRepository.syncPeriod(today).collect { progress -> teacherScheduleRepository.syncPeriod(today).collect { progress ->
_uiState.value = _uiState.value.copy(syncProgress = progress) _uiState.value = _uiState.value.copy(syncProgress = progress)
} }
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
_uiState.value = _uiState.value.copy(
error = e.message ?: "Не удалось загрузить расписание преподавателей"
)
} finally {
_uiState.value = _uiState.value.copy(teachersLoading = false)
} }
} }
@@ -151,10 +162,11 @@ class PickerViewModel(
teachersJob = viewModelScope.launch { teachersJob = viewModelScope.launch {
teacherScheduleRepository.observeTeachers(period).collect { teachers -> teacherScheduleRepository.observeTeachers(period).collect { teachers ->
val query = _uiState.value.teacherQuery val query = _uiState.value.teacherQuery
val isLoading = _uiState.value.teachersLoading
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
teachersLoading = false,
teachers = teachers, teachers = teachers,
filteredTeachers = filterTeachers(teachers, query), filteredTeachers = filterTeachers(teachers, query),
teachersLoading = isLoading && teachers.isEmpty(),
error = null error = null
) )
} }
@@ -178,11 +190,4 @@ class PickerViewModel(
fun retryTeachers() { fun retryTeachers() {
syncAndObserveTeachers() syncAndObserveTeachers()
} }
fun selectTeacher(teacher: Teacher) {
viewModelScope.launch {
preferencesRepository.clearSelectedGroup()
preferencesRepository.setSelectedTeacherId(teacher.name)
}
}
} }
@@ -132,12 +132,16 @@ fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher:
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
WeekSelector( WeekSelector(
periods = listOf(schedule.period), periods = state.weeks.ifEmpty { listOf(schedule.period) },
selectedWeekStart = state.weekStart, selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate?.let { todayDate -> todayWeekStart = state.todayDate
schedule.period.weekStart.takeIf { ?.let { todayDate ->
!todayDate.isBefore(it) && !todayDate.isAfter(it.plusDays(6)) state.weeks.firstOrNull { period ->
!todayDate.isBefore(period.weekStart) && !todayDate.isAfter(period.weekEnd)
}?.weekStart
} }
?: schedule.period.weekStart.takeIf {
state.todayDate?.let { today -> !today.isBefore(it) && !today.isAfter(it.plusDays(6)) } == true
}, },
onSelect = viewModel::selectWeek onSelect = viewModel::selectWeek
) )
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.temporal.TemporalAdjusters import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -27,6 +28,8 @@ class TeacherScheduleViewModel(
private var currentTeacherName: String? = null private var currentTeacherName: String? = null
private var currentPeriod: SchedulePeriod? = null private var currentPeriod: SchedulePeriod? = null
private var scheduleJob: Job? = null private var scheduleJob: Job? = null
private var weeksJob: Job? = null
private var loadRevision = 0
init { init {
viewModelScope.launch { viewModelScope.launch {
@@ -40,9 +43,11 @@ class TeacherScheduleViewModel(
} }
fun selectTeacher(name: String) { fun selectTeacher(name: String) {
val revision = ++loadRevision
currentTeacherName = name currentTeacherName = name
_uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false) _uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false)
viewModelScope.launch { viewModelScope.launch {
try {
preferencesRepository.setSelectedTeacherId(name) preferencesRepository.setSelectedTeacherId(name)
val savedWeekStart = runCatching { val savedWeekStart = runCatching {
@@ -55,6 +60,7 @@ class TeacherScheduleViewModel(
val today = ScheduleDates.moscowToday() val today = ScheduleDates.moscowToday()
val weekStart = savedWeekStart val weekStart = savedWeekStart
?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) ?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
noTeacher = false, noTeacher = false,
teacherName = name, teacherName = name,
@@ -67,25 +73,46 @@ class TeacherScheduleViewModel(
weekEnd = weekStart.plusDays(6), weekEnd = weekStart.plusDays(6),
season = ScheduleDates.currentSeason(today) season = ScheduleDates.currentSeason(today)
) )
loadSchedule(period) loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
} }
} }
private suspend fun loadSchedule(period: SchedulePeriod) { private suspend fun loadSchedule(period: SchedulePeriod, revision: Int) {
if (revision != loadRevision) return
scheduleJob?.cancel() scheduleJob?.cancel()
scheduleJob = null scheduleJob = null
currentPeriod = period currentPeriod = period
if (!scheduleRepository.hasData(period)) { if (!scheduleRepository.hasData(period)) {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false) _uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
runCatching { scheduleRepository.syncPeriod(period.weekStart).collect { } } try {
scheduleRepository.syncPeriod(period.weekStart).collect { }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
}
if (revision != loadRevision) return
if (!scheduleRepository.hasData(period)) { if (!scheduleRepository.hasData(period)) {
_uiState.value = _uiState.value.copy(isLoading = false, loadError = true) _uiState.value = _uiState.value.copy(isLoading = false, loadError = true)
return
} }
} }
weeksJob?.cancel()
weeksJob = viewModelScope.launch {
val weeks = scheduleRepository.weeksFor(period.weekStart)
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(weeks = weeks)
}
scheduleJob = viewModelScope.launch { scheduleJob = viewModelScope.launch {
scheduleRepository.observeSchedule(period).collect { schedule -> scheduleRepository.observeSchedule(period).collect { schedule ->
if (revision != loadRevision) {
throw CancellationException("stale schedule observation")
}
currentTeacherName?.let { applySchedule(schedule, it) } currentTeacherName?.let { applySchedule(schedule, it) }
} }
} }
@@ -122,8 +149,11 @@ class TeacherScheduleViewModel(
val state = _uiState.value val state = _uiState.value
val name = state.teacherName ?: return val name = state.teacherName ?: return
if (state.weekStart == weekStart) return if (state.weekStart == weekStart) return
val revision = ++loadRevision
viewModelScope.launch { viewModelScope.launch {
try {
preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString()) preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString())
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
weekStart = weekStart, weekStart = weekStart,
dayDate = null, dayDate = null,
@@ -136,7 +166,10 @@ class TeacherScheduleViewModel(
weekEnd = weekStart.plusDays(6), weekEnd = weekStart.plusDays(6),
season = ScheduleDates.currentSeason(weekStart) season = ScheduleDates.currentSeason(weekStart)
) )
loadSchedule(period) loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
} }
} }
@@ -153,14 +186,26 @@ class TeacherScheduleViewModel(
fun refresh() { fun refresh() {
val period = currentPeriod ?: return val period = currentPeriod ?: return
val revision = ++loadRevision
viewModelScope.launch { viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false) _uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
runCatching { scheduleRepository.syncPeriod(ScheduleDates.moscowToday()).collect { } } try {
loadSchedule(period) scheduleRepository.syncPeriod(ScheduleDates.moscowToday()).collect { }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
}
if (revision != loadRevision) return@launch
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
} }
} }
fun switchTeacher() { fun switchTeacher() {
loadRevision++
viewModelScope.launch { viewModelScope.launch {
currentTeacherName = null currentTeacherName = null
currentPeriod = null currentPeriod = null
@@ -183,5 +228,6 @@ data class TeacherScheduleUiState(
val teacherName: String? = null, val teacherName: String? = null,
val weekStart: LocalDate? = null, val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null, val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null val todayDate: LocalDate? = null,
val weeks: List<SchedulePeriod> = emptyList()
) )