Compare commits

..
Author SHA1 Message Date
Agzamus 84ae97b906 feat: group display on schedule screen 2026-09-03 19:59:21 +03:00
Agzamus 2069ec811a ci: removed "Upload APK as artifact" action step 2026-09-03 19:47:06 +03:00
12 changed files with 79 additions and 210 deletions
-6
View File
@@ -112,9 +112,3 @@ jobs:
files: ${{ steps.apk.outputs.apk_path }}
token: ${{ secrets.GITEA_TOKEN }}
server_url: ${{ gitea.server_url }}
- name: Upload APK as artifact
uses: actions/upload-artifact@v4
with:
name: vyatsu-schedule-${{ steps.version.outputs.version_name }}
path: ${{ steps.apk.outputs.apk_path }}
+1 -1
View File
@@ -41,5 +41,5 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
}
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository) }
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
@@ -15,16 +15,4 @@ interface PreferencesDao {
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
suspend fun clearSelectedGroup()
@Query("SELECT value FROM preferences WHERE key = 'selectedWeekStart'")
suspend fun selectedWeekStart(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedWeekStart', :weekStart)")
suspend fun setSelectedWeekStart(weekStart: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedDayDate'")
suspend fun selectedDayDate(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
suspend fun setSelectedDayDate(date: String)
}
@@ -42,7 +42,7 @@ interface ScheduleDao {
suspend fun trim(groupId: String, keep: Int)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDays(days: List<DayEntity>): List<Long>
suspend fun insertDays(days: List<DayEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLessons(lessons: List<LessonEntity>)
@@ -23,6 +23,9 @@ class GroupRepositoryImpl(
override suspend fun getGroups(): List<Group> =
groupDao.observeAll().first().map { it.toDomain() }
override suspend fun getGroup(id: String): Group? =
groupDao.getById(id)?.toDomain()
override suspend fun getFaculties(): List<String> = groupDao.getFaculties()
override suspend fun refreshIfStale(): Boolean {
@@ -9,8 +9,4 @@ class PreferencesRepositoryImpl(
override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup()
override suspend fun selectedWeekStart(): String? = dao.selectedWeekStart()
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
}
@@ -112,8 +112,11 @@ class ScheduleRepositoryImpl(
private suspend fun replaceDays(scheduleId: Long, days: List<Day>) {
scheduleDao.deleteDaysForSchedule(scheduleId)
val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) }
val dayIds = scheduleDao.insertDays(dayEntities)
val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) -> LocalDate.ofEpochDay(day.date) to id }
val dayIdByDate = mutableMapOf<LocalDate, Long>()
scheduleDao.insertDays(dayEntities)
for (day in dayEntities) {
dayIdByDate[LocalDate.ofEpochDay(day.date)] = day.id
}
val lessons = days.flatMap { day ->
day.intervals.flatMapIndexed { slotIndex, slot ->
slot.lessons.mapIndexed { lessonIndex, lesson ->
@@ -15,7 +15,4 @@ data class Schedule(
) {
fun periodOf(date: LocalDate): SchedulePeriod? =
periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) }
fun daysOf(period: SchedulePeriod): List<Day> =
days.filter { !it.date.isBefore(period.weekStart) && !it.date.isAfter(period.weekEnd) }
}
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
interface GroupRepository {
fun observeGroups(): Flow<List<Group>>
suspend fun getGroups(): List<Group>
suspend fun getGroup(id: String): Group?
suspend fun getFaculties(): List<String>
suspend fun refreshIfStale(): Boolean
suspend fun forceRefresh(): Boolean
@@ -4,8 +4,4 @@ interface PreferencesRepository {
suspend fun selectedGroupId(): String?
suspend fun setSelectedGroup(groupId: String)
suspend fun clearSelectedGroup()
suspend fun selectedWeekStart(): String?
suspend fun setSelectedWeekStart(weekStart: String)
suspend fun selectedDayDate(): String?
suspend fun setSelectedDayDate(date: String)
}
@@ -1,7 +1,6 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -14,7 +13,6 @@ 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.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -37,7 +35,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class)
@@ -47,7 +44,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text("Расписание") },
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
actions = {
TextButton(onClick = { onSwitchGroup() }) {
Text("Сменить группу")
@@ -56,9 +53,9 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
},
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
windowInsets = WindowInsets(0.dp)
)
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
when {
state.isSyncing && state.schedule == null -> {
Row(
@@ -86,17 +83,12 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
else -> {
val schedule = state.schedule!!
val weekDays = state.weekStart
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
?.let { schedule.daysOf(it) }
.orEmpty()
val day = schedule.days.firstOrNull { it.date == state.dayDate }
val day = schedule.days.getOrNull(state.selectedIndex)
if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
val isToday = state.selectedIndex == state.todayIndex
val dayLabel = if (isToday) "Сегодня " else ""
Text(
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
@@ -107,7 +99,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TextButton(onClick = { viewModel.clearSelectedPeriod() }) {
Text("Очистить эту неделю")
Text("Очистить неделю")
}
TextButton(onClick = { viewModel.clearAll() }) {
Text("Очистить всё")
@@ -115,19 +107,10 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
WeekChips(
periods = schedule.periods,
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
onSelect = viewModel::selectWeek
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
DayChips(
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
days = schedule.days,
selectedIndex = state.selectedIndex,
todayIndex = state.todayIndex,
onSelect = viewModel::selectDay
)
@@ -152,57 +135,20 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
}
@Composable
private fun WeekChips(
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(periods, key = { it.weekStart.toEpochDay() }) { period ->
val selected = selectedWeekStart == period.weekStart
val isTodayWeek = todayWeekStart == period.weekStart
FilterChip(
selected = selected,
onClick = { onSelect(period.weekStart) },
label = {
Text(
weekLabel(period),
style = MaterialTheme.typography.labelLarge
)
},
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayWeek && !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 DayChips(
days: List<Day>,
selectedDate: LocalDate?,
todayDate: LocalDate?,
onSelect: (LocalDate) -> Unit
selectedIndex: Int,
todayIndex: Int,
onSelect: (Int) -> Unit
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date
itemsIndexed(days) { index, day ->
val selected = selectedIndex == index
val isTodayChip = todayIndex == index
FilterChip(
selected = selected,
onClick = { onSelect(day.date) },
onClick = { onSelect(index) },
label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
@@ -237,11 +183,6 @@ private fun DayChips(
}
}
private fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@Composable
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
Column(Modifier.fillMaxWidth()) {
@@ -260,12 +201,8 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
style = MaterialTheme.typography.bodyLarge
)
Text(
listOfNotNull(
lesson.type.label,
lesson.subgroup,
lesson.teacher,
lesson.room
)
listOf(lesson.type.label, lesson.subgroup, lesson.teacher, lesson.room)
.filterNotNull()
.filter { it.isNotBlank() }
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
@@ -1,7 +1,9 @@
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.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository
import androidx.lifecycle.ViewModel
@@ -10,11 +12,11 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.LocalDate
class ScheduleViewModel(
private val scheduleRepository: ScheduleRepository,
private val preferencesRepository: PreferencesRepository
private val preferencesRepository: PreferencesRepository,
private val groupRepository: GroupRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ScheduleUiState())
@@ -37,103 +39,33 @@ class ScheduleViewModel(
currentGroupId = groupId
viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId)
val savedWeekStart = runCatching {
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
}.getOrNull()
val savedDayDate = runCatching {
preferencesRepository.selectedDayDate()?.let(LocalDate::parse)
}.getOrNull()
_uiState.value = _uiState.value.copy(
noGroup = false,
weekStart = savedWeekStart,
dayDate = savedDayDate
)
val groupName = groupRepository.getGroup(groupId)?.name
_uiState.value = _uiState.value.copy(noGroup = false, groupName = groupName)
launch {
scheduleRepository.observeSchedule(groupId).collect { schedule ->
currentGroupId?.let { applySchedule(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 {
syncIfMissingToday(groupId)
_uiState.value = _uiState.value.copy(isSyncing = true)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isSyncing = false)
}
}
}
private suspend fun applySchedule(schedule: Schedule?) {
val current = _uiState.value
if (schedule == null || schedule.periods.isEmpty()) {
_uiState.value = current.copy(schedule = schedule, todayDate = null)
return
}
val today = ScheduleDates.moscowToday()
val todayDate = if (schedule.periodOf(today) != null) today else null
val weekStart = when {
schedule.periods.any { it.weekStart == current.weekStart } -> current.weekStart!!
todayDate != null -> schedule.periodOf(todayDate)!!.weekStart
else -> schedule.periods.first().weekStart
}
val period = schedule.periods.first { it.weekStart == weekStart }
val weekDays = schedule.daysOf(period)
val dayDate = when {
weekDays.any { it.date == current.dayDate } -> current.dayDate
weekDays.any { it.date == todayDate } -> todayDate
else -> weekDays.firstOrNull()?.date
}
preferencesRepository.setSelectedWeekStart(weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
_uiState.value = current.copy(
schedule = schedule,
weekStart = weekStart,
dayDate = dayDate,
todayDate = todayDate
)
}
fun selectWeek(weekStart: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
viewModelScope.launch {
val today = ScheduleDates.moscowToday()
val weekDays = schedule.daysOf(period)
val dayDate = when {
weekDays.any { it.date == today } -> today
else -> weekDays.firstOrNull()?.date
}
preferencesRepository.setSelectedWeekStart(weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
_uiState.value = _uiState.value.copy(weekStart = weekStart, dayDate = dayDate)
}
}
fun selectDay(date: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
val period = schedule.periodOf(date) ?: return
viewModelScope.launch {
preferencesRepository.setSelectedWeekStart(period.weekStart.toString())
preferencesRepository.setSelectedDayDate(date.toString())
_uiState.value = _uiState.value.copy(weekStart = period.weekStart, dayDate = date)
}
}
private suspend fun syncIfMissingToday(groupId: String) {
val schedule = scheduleRepository.getSchedule(groupId)
val today = ScheduleDates.moscowToday()
val hasTodayPeriod = schedule?.periodOf(today) != null
if (hasTodayPeriod) return
_uiState.value = _uiState.value.copy(isSyncing = true)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isSyncing = false)
}
fun switchGroup() {
viewModelScope.launch {
currentGroupId = null
@@ -147,10 +79,23 @@ class ScheduleViewModel(
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isRefreshing = false)
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)
}
fun clearAll() {
val groupId = currentGroupId ?: return
viewModelScope.launch {
@@ -161,20 +106,29 @@ class ScheduleViewModel(
fun clearSelectedPeriod() {
val state = _uiState.value
val schedule = state.schedule ?: return
val weekStart = state.weekStart ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
val day = schedule.days.getOrNull(state.selectedIndex) ?: return
val period = schedule.periodOf(day.date) ?: return
viewModelScope.launch {
scheduleRepository.clearPeriod(schedule.groupId, period)
}
}
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 groupName: String? = null,
val schedule: Schedule? = null,
val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null
val todayIndex: Int = 0,
val selectedIndex: Int = 0
)