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