From 2dd8b8c469b399b9db5be0e0a7fd77c6f81d1876 Mon Sep 17 00:00:00 2001 From: Agzamus Date: Sat, 19 Sep 2026 19:12:19 +0300 Subject: [PATCH] feat(ui): add teacher schedule screen and view model --- .../ui/teacher/TeacherScheduleScreen.kt | 184 +++++++++++++++++ .../ui/teacher/TeacherScheduleViewModel.kt | 187 ++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleScreen.kt create mode 100644 app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleViewModel.kt diff --git a/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleScreen.kt b/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleScreen.kt new file mode 100644 index 0000000..875ab4c --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleScreen.kt @@ -0,0 +1,184 @@ +package agzam.vyatsu.ui.teacher + +import agzam.vyatsu.ui.schedule.components.DayChips +import agzam.vyatsu.ui.schedule.components.FULL_DATE +import agzam.vyatsu.ui.schedule.components.LessonSlot +import agzam.vyatsu.ui.schedule.components.WeekSelector +import agzam.vyatsu.ui.schedule.components.dayName +import agzam.vyatsu.ui.schedule.components.hPadding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher: () -> Unit) { + + val state by viewModel.uiState.collectAsState() + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text(state.teacherName ?: "Расписание преподавателя", style = MaterialTheme.typography.titleMedium) }, + actions = { + if (state.isLoading && state.schedule != null) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } + TextButton(onClick = { onSwitchTeacher() }) { + Text("Сменить преподавателя") + } + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Обновить") + } + }, + windowInsets = WindowInsets(0.dp) + ) + Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) { + when { + state.schedule == null && state.loadError -> { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + "Не удалось загрузить расписание", + style = MaterialTheme.typography.bodyLarge + ) + TextButton(onClick = { viewModel.refresh() }) { + Text("Повторить") + } + } + } + + state.schedule == null -> { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + } + } + + else -> { + val schedule = state.schedule!! + val weekDays = schedule.days + val day = weekDays.firstOrNull { it.date == state.dayDate } + + if (day == null) { + Text("Занятий нет", style = MaterialTheme.typography.bodyLarge) + } else { + + val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0) + val pagerState = rememberPagerState( + initialPage = selectedDayIndex, + pageCount = { weekDays.size } + ) + LaunchedEffect(state.dayDate) { + val targetPage = weekDays.indexOfFirst { it.date == state.dayDate } + if (targetPage != -1 && targetPage != pagerState.currentPage) { + pagerState.scrollToPage(targetPage) + } + } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + if (pagerState.isScrollInProgress && page in weekDays.indices) { + viewModel.selectDay(weekDays[page].date) + } + } + } + + val isTodayDay = day.date == state.todayDate + val dayLabel = if (isTodayDay) "Сегодня " else "" + + Row( + Modifier.fillMaxWidth().padding(horizontal = hPadding), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f) + ) + WeekSelector( + periods = listOf(schedule.period), + selectedWeekStart = state.weekStart, + todayWeekStart = state.todayDate?.let { todayDate -> + schedule.period.weekStart.takeIf { + !todayDate.isBefore(it) && !todayDate.isAfter(it.plusDays(6)) + } + }, + onSelect = viewModel::selectWeek + ) + } + HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding)) + + DayChips( + days = weekDays, + selectedDate = state.dayDate, + todayDate = state.todayDate, + onSelect = viewModel::selectDay, + pagerState = pagerState + ) + + HorizontalDivider(Modifier.padding(top = 0.dp, start = hPadding, end = hPadding)) + + HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page -> + val pagerDay = weekDays.getOrNull(page) + + if (pagerDay == null || pagerDay.intervals.all { it.lessons.isEmpty() }) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Занятий нет", style = MaterialTheme.typography.bodyLarge) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + itemsIndexed(pagerDay.intervals) { slotIndex, slot -> + LessonSlot(slotIndex = slotIndex, slot = slot) + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleViewModel.kt b/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleViewModel.kt new file mode 100644 index 0000000..815afb9 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/ui/teacher/TeacherScheduleViewModel.kt @@ -0,0 +1,187 @@ +package agzam.vyatsu.ui.teacher + +import agzam.vyatsu.domain.ScheduleDates +import agzam.vyatsu.domain.model.SchedulePeriod +import agzam.vyatsu.domain.model.TeacherSchedule +import agzam.vyatsu.domain.repository.PreferencesRepository +import agzam.vyatsu.domain.repository.TeacherScheduleRepository +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class TeacherScheduleViewModel( + private val scheduleRepository: TeacherScheduleRepository, + private val preferencesRepository: PreferencesRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(TeacherScheduleUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var currentTeacherName: String? = null + private var currentPeriod: SchedulePeriod? = null + private var scheduleJob: Job? = null + + init { + viewModelScope.launch { + val teacherId = preferencesRepository.selectedTeacherId() + if (teacherId != null) { + selectTeacher(teacherId) + } else { + _uiState.value = _uiState.value.copy(noTeacher = true, teacherName = null) + } + } + } + + fun selectTeacher(name: String) { + currentTeacherName = name + _uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false) + viewModelScope.launch { + preferencesRepository.setSelectedTeacherId(name) + + val savedWeekStart = runCatching { + preferencesRepository.selectedTeacherWeekStart()?.let(LocalDate::parse) + }.getOrNull() + val savedDayDate = runCatching { + preferencesRepository.selectedTeacherDayDate()?.let(LocalDate::parse) + }.getOrNull() + + val today = ScheduleDates.moscowToday() + val weekStart = savedWeekStart + ?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + _uiState.value = _uiState.value.copy( + noTeacher = false, + teacherName = name, + weekStart = weekStart, + dayDate = savedDayDate + ) + + val period = SchedulePeriod( + weekStart = weekStart, + weekEnd = weekStart.plusDays(6), + season = ScheduleDates.currentSeason(today) + ) + loadSchedule(period) + } + } + + private suspend fun loadSchedule(period: SchedulePeriod) { + 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 { } } + if (!scheduleRepository.hasData(period)) { + _uiState.value = _uiState.value.copy(isLoading = false, loadError = true) + } + } + + scheduleJob = viewModelScope.launch { + scheduleRepository.observeSchedule(period).collect { schedule -> + currentTeacherName?.let { applySchedule(schedule, it) } + } + } + } + + private suspend fun applySchedule(schedule: TeacherSchedule?, teacherName: String) { + val current = _uiState.value + if (schedule == null) return + + val filteredDays = schedule.daysForTeacher(teacherName) + val today = ScheduleDates.moscowToday() + val todayDate = if (isInPeriod(today, schedule.period)) today else null + + val dayDate = when { + filteredDays.any { it.date == current.dayDate } -> current.dayDate + filteredDays.any { it.date == todayDate } -> todayDate + else -> filteredDays.firstOrNull()?.date + } + + preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString()) + dayDate?.let { preferencesRepository.setSelectedTeacherDayDate(it.toString()) } + + _uiState.value = current.copy( + schedule = schedule.copy(days = filteredDays), + weekStart = schedule.period.weekStart, + dayDate = dayDate, + todayDate = todayDate, + isLoading = false, + loadError = false + ) + } + + fun selectWeek(weekStart: LocalDate) { + val state = _uiState.value + val name = state.teacherName ?: return + if (state.weekStart == weekStart) return + viewModelScope.launch { + preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString()) + _uiState.value = _uiState.value.copy( + weekStart = weekStart, + dayDate = null, + schedule = null, + isLoading = true, + loadError = false + ) + val period = SchedulePeriod( + weekStart = weekStart, + weekEnd = weekStart.plusDays(6), + season = ScheduleDates.currentSeason(weekStart) + ) + loadSchedule(period) + } + } + + fun selectDay(date: LocalDate) { + val state = _uiState.value + val schedule = state.schedule ?: return + if (date.isBefore(schedule.period.weekStart) || date.isAfter(schedule.period.weekEnd)) return + viewModelScope.launch { + preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString()) + preferencesRepository.setSelectedTeacherDayDate(date.toString()) + _uiState.value = _uiState.value.copy(weekStart = schedule.period.weekStart, dayDate = date) + } + } + + fun refresh() { + val period = currentPeriod ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true, loadError = false) + runCatching { scheduleRepository.syncPeriod(ScheduleDates.moscowToday()).collect { } } + loadSchedule(period) + } + } + + fun switchTeacher() { + viewModelScope.launch { + currentTeacherName = null + currentPeriod = null + scheduleJob?.cancel() + scheduleJob = null + preferencesRepository.clearSelectedTeacher() + _uiState.value = TeacherScheduleUiState(noTeacher = true) + } + } + + private fun isInPeriod(date: LocalDate, period: SchedulePeriod): Boolean = + !date.isBefore(period.weekStart) && !date.isAfter(period.weekEnd) +} + +data class TeacherScheduleUiState( + val noTeacher: Boolean = true, + val isLoading: Boolean = false, + val loadError: Boolean = false, + val schedule: TeacherSchedule? = null, + val teacherName: String? = null, + val weekStart: LocalDate? = null, + val dayDate: LocalDate? = null, + val todayDate: LocalDate? = null +) \ No newline at end of file