feat(ui): add teacher schedule screen and view model

This commit is contained in:
2026-09-19 19:12:19 +03:00
parent 6c06892d9a
commit 2dd8b8c469
2 changed files with 371 additions and 0 deletions
@@ -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)
}
}
}
}
}
}
}
}
}
}
@@ -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<TeacherScheduleUiState> = _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
)