feat(ui): add teacher schedule screen and view model
This commit is contained in:
@@ -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
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user