diff --git a/app/src/main/java/agzam/vyatsu/data/local/PreferencesDao.kt b/app/src/main/java/agzam/vyatsu/data/local/PreferencesDao.kt index b7ac864..a5ac5f0 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/PreferencesDao.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/PreferencesDao.kt @@ -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) } diff --git a/app/src/main/java/agzam/vyatsu/data/local/ScheduleDao.kt b/app/src/main/java/agzam/vyatsu/data/local/ScheduleDao.kt index eb055a0..afeb5ad 100644 --- a/app/src/main/java/agzam/vyatsu/data/local/ScheduleDao.kt +++ b/app/src/main/java/agzam/vyatsu/data/local/ScheduleDao.kt @@ -42,7 +42,7 @@ interface ScheduleDao { suspend fun trim(groupId: String, keep: Int) @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertDays(days: List) + suspend fun insertDays(days: List): List @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertLessons(lessons: List) diff --git a/app/src/main/java/agzam/vyatsu/data/repository/PreferencesRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/PreferencesRepositoryImpl.kt index 93452cf..5ad238e 100644 --- a/app/src/main/java/agzam/vyatsu/data/repository/PreferencesRepositoryImpl.kt +++ b/app/src/main/java/agzam/vyatsu/data/repository/PreferencesRepositoryImpl.kt @@ -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) } diff --git a/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt b/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt index 9c5df46..be28ecf 100644 --- a/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt +++ b/app/src/main/java/agzam/vyatsu/data/repository/ScheduleRepositoryImpl.kt @@ -112,11 +112,8 @@ class ScheduleRepositoryImpl( private suspend fun replaceDays(scheduleId: Long, days: List) { scheduleDao.deleteDaysForSchedule(scheduleId) val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) } - val dayIdByDate = mutableMapOf() - scheduleDao.insertDays(dayEntities) - for (day in dayEntities) { - dayIdByDate[LocalDate.ofEpochDay(day.date)] = day.id - } + 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 -> diff --git a/app/src/main/java/agzam/vyatsu/domain/model/Schedule.kt b/app/src/main/java/agzam/vyatsu/domain/model/Schedule.kt index 4d24b76..06ef506 100644 --- a/app/src/main/java/agzam/vyatsu/domain/model/Schedule.kt +++ b/app/src/main/java/agzam/vyatsu/domain/model/Schedule.kt @@ -15,4 +15,7 @@ data class Schedule( ) { fun periodOf(date: LocalDate): SchedulePeriod? = periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) } + + fun daysOf(period: SchedulePeriod): List = + days.filter { !it.date.isBefore(period.weekStart) && !it.date.isAfter(period.weekEnd) } } diff --git a/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt b/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt index bcadd2f..c4a6307 100644 --- a/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt +++ b/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt @@ -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) } diff --git a/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleScreen.kt b/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleScreen.kt index 64defc0..34067d4 100644 --- a/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleScreen.kt +++ b/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleScreen.kt @@ -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) @@ -55,7 +58,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) { }, 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,12 +86,17 @@ 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)}", @@ -99,7 +107,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { TextButton(onClick = { viewModel.clearSelectedPeriod() }) { - Text("Очистить неделю") + Text("Очистить эту неделю") } TextButton(onClick = { viewModel.clearAll() }) { Text("Очистить всё") @@ -107,10 +115,19 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) { } 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 ) @@ -136,19 +153,56 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) { } @Composable -private fun DayChips( - days: List, - selectedIndex: Int, - todayIndex: Int, - onSelect: (Int) -> Unit +private fun WeekChips( + periods: List, + 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, + 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( @@ -183,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()) { @@ -201,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, @@ -226,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") \ No newline at end of file +private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") diff --git a/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleViewModel.kt b/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleViewModel.kt index 6fa2143..c976b92 100644 --- a/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/agzam/vyatsu/ui/schedule/ScheduleViewModel.kt @@ -1,9 +1,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 @@ -12,11 +10,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.time.LocalDate class ScheduleViewModel( private val scheduleRepository: ScheduleRepository, - private val preferencesRepository: PreferencesRepository, - private val groupRepository: GroupRepository + private val preferencesRepository: PreferencesRepository ) : ViewModel() { private val _uiState = MutableStateFlow(ScheduleUiState()) @@ -39,33 +37,103 @@ class ScheduleViewModel( currentGroupId = groupId viewModelScope.launch { preferencesRepository.setSelectedGroup(groupId) - val groupName = groupRepository.getGroup(groupId)?.name - _uiState.value = _uiState.value.copy(noGroup = false, groupName = groupName) + + 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 { scheduleRepository.observeSchedule(groupId).collect { schedule -> - currentGroupId?.let { - 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 - ) - } + currentGroupId?.let { applySchedule(schedule) } } } launch { - _uiState.value = _uiState.value.copy(isSyncing = true) - scheduleRepository.sync(groupId) - _uiState.value = _uiState.value.copy(isSyncing = false) + syncIfMissingToday(groupId) } } } + 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() { viewModelScope.launch { currentGroupId = null @@ -79,23 +147,10 @@ class ScheduleViewModel( viewModelScope.launch { _uiState.value = _uiState.value.copy(isRefreshing = true) scheduleRepository.sync(groupId) - val current = _uiState.value - 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 - ) + _uiState.value = _uiState.value.copy(isRefreshing = false) } } - fun selectDay(index: Int) { - val days = _uiState.value.schedule?.days.orEmpty() - if (index !in days.indices) return - _uiState.value = _uiState.value.copy(selectedIndex = index) - } - fun clearAll() { val groupId = currentGroupId ?: return viewModelScope.launch { @@ -106,29 +161,20 @@ class ScheduleViewModel( 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 + val weekStart = state.weekStart ?: return + val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return viewModelScope.launch { scheduleRepository.clearPeriod(schedule.groupId, period) } } - - private fun todayIndex(days: List): Int { - if (days.isEmpty()) return 0 - val today = ScheduleDates.moscowToday() - val exact = days.indexOfFirst { it.date == today } - if (exact >= 0) return exact - val next = days.indexOfFirst { !it.date.isBefore(today) } - return if (next >= 0) next else days.lastIndex - } } 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 + val weekStart: LocalDate? = null, + val dayDate: LocalDate? = null, + val todayDate: LocalDate? = null )