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(
"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>>
@Query(
"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 " +
"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"
)
fun observeLessonsForPeriod(season: String, weekStart: Long): Flow<List<TeacherLessonEntity>>
@@ -55,12 +57,14 @@ interface TeacherScheduleDao {
@Query(
"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 " +
"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>>
@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
}
@@ -26,17 +26,24 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalTime
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
class TeacherScheduleRepositoryImpl(
private val scheduleDao: TeacherScheduleDao,
private val teacherDao: TeacherDao
) : TeacherScheduleRepository {
private val syncMutex = Mutex()
override fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> =
combine(
scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()),
@@ -50,12 +57,15 @@ class TeacherScheduleRepositoryImpl(
.map { names -> names.map { name -> Teacher(name = name) } }
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) {
teacherDao.getPeriodsForDate(date.toEpochDay())
}
if (targets.isEmpty()) {
emit(BulkSyncProgress(0, 0, 0))
return@flow
return@withLock
}
targets.map { it.season to it.weekStart }
@@ -75,6 +85,8 @@ class TeacherScheduleRepositoryImpl(
try {
withContext(Dispatchers.IO) { syncDepartment(target) }
true
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Log.e(
TAG,
@@ -99,12 +111,37 @@ class TeacherScheduleRepositoryImpl(
withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) }
}
}
override suspend fun hasData(period: SchedulePeriod): Boolean =
withContext(Dispatchers.IO) {
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) {
scheduleDao.deleteAll()
}
@@ -182,9 +219,13 @@ class TeacherScheduleRepositoryImpl(
dayEntities: List<TeacherDayEntity>,
lessonEntities: List<TeacherLessonEntity>
): 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 days = dayEntities
val days = weekDays
.sortedBy { it.date }
.distinctBy { it.date }
.map { day ->
@@ -23,6 +23,7 @@ class AppContainer(context: Context) {
AppDatabase::class.java,
"vyatsu_schedule.db"
)
.addMigrations(AppDatabase.MIGRATION_3_4)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -12,5 +12,6 @@ interface TeacherScheduleRepository {
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress>
suspend fun hasData(period: SchedulePeriod): Boolean
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
suspend fun clearAll()
}
@@ -13,6 +13,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.time.DayOfWeek
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -45,6 +46,7 @@ class PickerViewModel(
val uiState: StateFlow<PickerUiState> = _uiState.asStateFlow()
private var teachersJob: kotlinx.coroutines.Job? = null
private var syncJob: kotlinx.coroutines.Job? = null
init {
groupsLoad()
@@ -139,11 +141,20 @@ class PickerViewModel(
_uiState.value = _uiState.value.copy(syncProgress = null, teachersLoading = true)
viewModelScope.launch {
runCatching {
syncJob?.cancel()
syncJob = viewModelScope.launch {
try {
teacherScheduleRepository.syncPeriod(today).collect { 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 {
teacherScheduleRepository.observeTeachers(period).collect { teachers ->
val query = _uiState.value.teacherQuery
val isLoading = _uiState.value.teachersLoading
_uiState.value = _uiState.value.copy(
teachersLoading = false,
teachers = teachers,
filteredTeachers = filterTeachers(teachers, query),
teachersLoading = isLoading && teachers.isEmpty(),
error = null
)
}
@@ -178,11 +190,4 @@ class PickerViewModel(
fun retryTeachers() {
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)
)
WeekSelector(
periods = listOf(schedule.period),
periods = state.weeks.ifEmpty { listOf(schedule.period) },
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate?.let { todayDate ->
schedule.period.weekStart.takeIf {
!todayDate.isBefore(it) && !todayDate.isAfter(it.plusDays(6))
todayWeekStart = state.todayDate
?.let { todayDate ->
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
)
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -27,6 +28,8 @@ class TeacherScheduleViewModel(
private var currentTeacherName: String? = null
private var currentPeriod: SchedulePeriod? = null
private var scheduleJob: Job? = null
private var weeksJob: Job? = null
private var loadRevision = 0
init {
viewModelScope.launch {
@@ -40,9 +43,11 @@ class TeacherScheduleViewModel(
}
fun selectTeacher(name: String) {
val revision = ++loadRevision
currentTeacherName = name
_uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false)
viewModelScope.launch {
try {
preferencesRepository.setSelectedTeacherId(name)
val savedWeekStart = runCatching {
@@ -55,6 +60,7 @@ class TeacherScheduleViewModel(
val today = ScheduleDates.moscowToday()
val weekStart = savedWeekStart
?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(
noTeacher = false,
teacherName = name,
@@ -67,25 +73,46 @@ class TeacherScheduleViewModel(
weekEnd = weekStart.plusDays(6),
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 = null
currentPeriod = period
if (!scheduleRepository.hasData(period)) {
_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)) {
_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 {
scheduleRepository.observeSchedule(period).collect { schedule ->
if (revision != loadRevision) {
throw CancellationException("stale schedule observation")
}
currentTeacherName?.let { applySchedule(schedule, it) }
}
}
@@ -122,8 +149,11 @@ class TeacherScheduleViewModel(
val state = _uiState.value
val name = state.teacherName ?: return
if (state.weekStart == weekStart) return
val revision = ++loadRevision
viewModelScope.launch {
try {
preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString())
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(
weekStart = weekStart,
dayDate = null,
@@ -136,7 +166,10 @@ class TeacherScheduleViewModel(
weekEnd = weekStart.plusDays(6),
season = ScheduleDates.currentSeason(weekStart)
)
loadSchedule(period)
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
}
}
@@ -153,14 +186,26 @@ class TeacherScheduleViewModel(
fun refresh() {
val period = currentPeriod ?: return
val revision = ++loadRevision
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
runCatching { scheduleRepository.syncPeriod(ScheduleDates.moscowToday()).collect { } }
loadSchedule(period)
try {
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() {
loadRevision++
viewModelScope.launch {
currentTeacherName = null
currentPeriod = null
@@ -183,5 +228,6 @@ data class TeacherScheduleUiState(
val teacherName: String? = null,
val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null
val todayDate: LocalDate? = null,
val weeks: List<SchedulePeriod> = emptyList()
)