forked from agzam/vyatsu-schedule
init
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package agzam.vyatsu.ui.schedule
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.only
|
||||
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.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text("Расписание") },
|
||||
actions = {
|
||||
TextButton(onClick = { onSwitchGroup() }) {
|
||||
Text("Сменить группу")
|
||||
}
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
|
||||
)
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
||||
when {
|
||||
state.isSyncing && state.schedule == null -> {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
"Не удалось загрузить расписание",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
TextButton(onClick = { viewModel.refresh() }) {
|
||||
Text("Повторить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val schedule = state.schedule!!
|
||||
val day = schedule.days.getOrNull(state.selectedIndex)
|
||||
if (day == null) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
val isToday = state.selectedIndex == state.todayIndex
|
||||
val dayLabel = if (isToday) "Сегодня " else ""
|
||||
|
||||
Text(
|
||||
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
DayChips(
|
||||
days = schedule.days,
|
||||
selectedIndex = state.selectedIndex,
|
||||
todayIndex = state.todayIndex,
|
||||
onSelect = viewModel::selectDay
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayChips(
|
||||
days: List<Day>,
|
||||
selectedIndex: Int,
|
||||
todayIndex: Int,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
itemsIndexed(days) { index, day ->
|
||||
val selected = selectedIndex == index
|
||||
val isTodayChip = todayIndex == index
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onSelect(index) },
|
||||
label = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
dayName(day.date.dayOfWeek),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Text(
|
||||
day.date.format(CHIP_DATE),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
border = if (isTodayChip && !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 LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (slot.lessons.isEmpty()) {
|
||||
Text("—", style = MaterialTheme.typography.bodyMedium)
|
||||
} else {
|
||||
slot.lessons.forEach { lesson ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
listOf(lesson.type.label, lesson.subgroup, lesson.teacher, lesson.room)
|
||||
.filterNotNull()
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
||||
DayOfWeek.MONDAY -> "Пн"
|
||||
DayOfWeek.TUESDAY -> "Вт"
|
||||
DayOfWeek.WEDNESDAY -> "Ср"
|
||||
DayOfWeek.THURSDAY -> "Чт"
|
||||
DayOfWeek.FRIDAY -> "Пт"
|
||||
DayOfWeek.SATURDAY -> "Сб"
|
||||
DayOfWeek.SUNDAY -> "Вс"
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,113 @@
|
||||
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.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ScheduleViewModel(
|
||||
private val scheduleRepository: ScheduleRepository,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var currentGroupId: String? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val groupId = preferencesRepository.selectedGroupId()
|
||||
if (groupId != null) {
|
||||
selectGroup(groupId)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectGroup(groupId: String) {
|
||||
currentGroupId = groupId
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setSelectedGroup(groupId)
|
||||
_uiState.value = _uiState.value.copy(noGroup = false)
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
_uiState.value = _uiState.value.copy(isSyncing = true)
|
||||
scheduleRepository.sync(groupId)
|
||||
_uiState.value = _uiState.value.copy(isSyncing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchGroup() {
|
||||
viewModelScope.launch {
|
||||
currentGroupId = null
|
||||
preferencesRepository.clearSelectedGroup()
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val groupId = currentGroupId ?: return
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectDay(index: Int) {
|
||||
val days = _uiState.value.schedule?.days.orEmpty()
|
||||
if (index !in days.indices) return
|
||||
_uiState.value = _uiState.value.copy(selectedIndex = index)
|
||||
}
|
||||
|
||||
private fun todayIndex(days: List<Day>): 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 schedule: Schedule? = null,
|
||||
val todayIndex: Int = 0,
|
||||
val selectedIndex: Int = 0
|
||||
)
|
||||
Reference in New Issue
Block a user