Compare commits

..
12 Commits
Author SHA1 Message Date
Agzamus 014896aaea ui: no more empty lines in schedule 2026-09-05 00:21:39 +03:00
Agzamus 9f6cf3ef30 fix: #1 - text blocks by words 2026-09-05 00:13:38 +03:00
Agzamus d1923d3296 ci: auto version_name 2026-09-04 04:43:43 +03:00
Agzamus 686976347e feat: better parsing & display 2026-09-04 04:40:29 +03:00
Agzamus 699bcd81f5 ui: header group display 2026-09-04 02:05:52 +03:00
Agzamus 050f835a33 ui: reduced maket padding 2026-09-04 01:22:35 +03:00
Agzamus 5fee57d93a ui: better week select l&f 2026-09-04 01:07:09 +03:00
Agzamus 36711d1bf3 ui: smaller week select 2026-09-03 23:34:45 +03:00
Agzamus c2a9cfb7e7 refactor: weeks select -> combobox 2026-09-03 23:16:57 +03:00
Agzamus 467c1a735f fix: Key "XXX" was already used. 2026-09-03 23:01:05 +03:00
Agzamus 28965e6aed Merge branch 'database-refactor' 2026-09-03 22:54:49 +03:00
Agzamus 181f8b2f2a Merge branch 'database-refactor' 2026-09-03 22:53:26 +03:00
10 changed files with 351 additions and 137 deletions
+3
View File
@@ -85,6 +85,9 @@ jobs:
- name: Build release APK
run: ./gradlew assembleRelease
env:
# Версия приложения — дата, сгенерированная на предыдущем шаге.
# Читается в app/build.gradle.kts как versionName (для BuildConfig).
APP_VERSION: ${{ steps.version.outputs.version_name }}
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+3 -1
View File
@@ -17,7 +17,9 @@ android {
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "1.0"
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
// Локально и при отсутствии переменной — fallback "1.0".
versionName = System.getenv("APP_VERSION") ?: "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -38,7 +38,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
private val svgBuilder = StringBuilder()
private val currentPath = StringBuilder()
private val tableSvg = StringBuilder()
val tableSvg = StringBuilder()
private val currentSubPathLines = mutableListOf<PdfLine>()
@@ -52,7 +52,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
val width = mediaBox.width
val height = mediaBox.height
return """
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style='font-size: 50%;'>
<g>${svgBuilder}</g>
<g>${tableSvg}</g>
</svg>
@@ -211,7 +211,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
val bottom = ys[ii+1]
cells.add(TableCell(left, top, right-left, bottom-top))
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f" else "#0"}${if (ii % 2 == 0) "f0" else "00"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
}
columns.add(TableColumn(left, right-left, cells))
}
@@ -2,6 +2,7 @@ package agzam.vyatsu.data.remote.pdf
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.TimeSlot
import android.util.Log
import com.tom_roush.pdfbox.pdmodel.PDDocument
@@ -25,6 +26,10 @@ object PdfScheduleParser {
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } )
private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
fun parse(pdfBytes: ByteArray): List<Day> {
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
val doc = try {
@@ -35,6 +40,8 @@ object PdfScheduleParser {
}
val days = mutableListOf<Day>()
var group: String? = null
try {
for (pageIndex in 0 until doc.numberOfPages) {
Log.v(TAG, "============ Page $pageIndex ============")
@@ -45,12 +52,6 @@ object PdfScheduleParser {
svg.processPage(page)
val cols = svg.asTable()
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
for(line in svg.asSvg().split("\n")) {
Log.v(SVG_TAG, line)
}
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
// Парсинг текста
@@ -60,6 +61,11 @@ object PdfScheduleParser {
textStripper.getText(doc)
val texts = textStripper.blocks
for(text in texts) {
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
}
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
@@ -100,10 +106,17 @@ object PdfScheduleParser {
}
}
for (dayCell in daysColumn.cells) {
val day = content(dayCell).split(' ')
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
if(day.size == 1 && day.first() == "День") {
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
group = content(groupCell)
continue
}
if(day.size != 2) {
Log.w(TAG, "Day data is not in format <day> <date>: $day")
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
}
val date = dateFrom(day[1]) ?: continue
val intervals = mutableListOf<TimeSlot>()
@@ -124,7 +137,55 @@ object PdfScheduleParser {
val content = content(lessonCell)
Log.v(TAG, "- $content")
// TODO: парсинг контента лекции
lessons.add(Lesson(raw=content))
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
var found = 0
var currentStart = 0
for (roomMatch in ROOM_REGEX.findAll(content)) {
found++
val raw = content.substring(currentStart, roomMatch.range.last + 1)
currentStart = roomMatch.range.last + 1
// Предполагаемый формат: [группа], [XX подгруппа] <предмет> <тип> [id + тип] <ФИО> <кабинет>
val room = roomMatch.value
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList()
if(typeMatch.isEmpty()) {
lessons.add(Lesson(
raw=raw,
room=room,
))
continue
}
var subject = raw.substring(0, typeMatch.first().range.first).trim() // TODO: сейчас [группа], [XX подгруппа] <предмет> - можно достать подгруппу
val type = LessonType.fromRaw(typeMatch.first().value.trim())
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
var subgroup: String? = null
Log.d(TAG, "$subject $group")
if(group != null && subject.startsWith("$group,")) {
subject = subject.substring(group.length + 1).trim()
val subgroupMatch = SUBGROUP_REGEX.find(subject)
if(subgroupMatch != null) {
subgroup = subgroupMatch.value.trim()
subject = subject.removeRange(subgroupMatch.range).trim()
}
}
lessons.add(Lesson(
raw=raw,
subject=subject,
room=room,
type=type,
teacher=teacher,
subgroup=subgroup
))
}
if(found == 0) lessons.add(Lesson(raw=content))
}
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
}
@@ -133,6 +194,18 @@ object PdfScheduleParser {
for (text in texts.filter { it.rotation != 0f }) {
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
}
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
for(line in svg.asSvg().split("\n")) {
if(line.length > 250) {
Log.v(SVG_TAG, "length: " + line.length)
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
continue
}
Log.v(SVG_TAG, line)
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
}
}
} catch (e: Exception) {
Log.e(TAG, "Parsing error", e)
@@ -7,6 +7,7 @@ import com.tom_roush.pdfbox.text.TextPosition
* Парсер блоков текста из PDF
* @author Agzam
*/
class PdfTextParser : PDFTextStripper() {
data class TextBlock(
@@ -25,9 +26,32 @@ class PdfTextParser : PDFTextStripper() {
}
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
val first = textPositions.first()
val last = textPositions.last()
if (textPositions.isNullOrEmpty()) return
val currentWordChars = mutableListOf<TextPosition>()
for (tp in textPositions) {
val unicode = tp.unicode
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
addWordBlock(currentWordChars)
currentWordChars.clear()
} else {
currentWordChars.add(tp)
}
}
addWordBlock(currentWordChars)
}
private fun addWordBlock(wordChars: List<TextPosition>) {
if (wordChars.isEmpty()) return
val first = wordChars.first()
val last = wordChars.last()
val wordText = wordChars.joinToString("") { it.unicode }.trim()
if (wordText.isEmpty()) return
val matrix = first.textMatrix
val rotation = first.dir
@@ -36,10 +60,17 @@ class PdfTextParser : PDFTextStripper() {
val x = matrix.translateX
val y = matrix.translateY - first.heightDir
val height = first.heightDir
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
}
blocks.add(
TextBlock(
text = wordText,
x = x,
y = currentPage.mediaBox.height - y - height,
width = totalWidth,
height = height,
rotation = rotation
)
)
}
}
@@ -23,9 +23,6 @@ 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 {
@@ -157,6 +157,7 @@ class ScheduleRepositoryImpl(
val lessonsByDay = lessonEntities.groupBy { it.dayId }
val days = dayEntities
.sortedBy { it.date }
.distinctBy { it.date }
.map { day ->
val lessons = lessonsByDay[day.id].orEmpty()
.groupBy { it.slotIndex }
@@ -6,7 +6,6 @@ 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
@@ -1,9 +1,12 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
@@ -12,14 +15,18 @@ 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.layout.widthIn
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
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
@@ -33,8 +40,12 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.LocalDate
@@ -98,31 +109,23 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
Text(
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { viewModel.clearSelectedPeriod() }) {
Text("Очистить эту неделю")
}
TextButton(onClick = { viewModel.clearAll() }) {
Text("Очистить всё")
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
WeekChips(
Text(
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
WeekSelector(
periods = schedule.periods,
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
onSelect = viewModel::selectWeek
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
}
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp))
DayChips(
days = weekDays,
@@ -131,7 +134,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
onSelect = viewModel::selectDay
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
HorizontalDivider(Modifier.padding(top=0.dp, bottom = 8.dp))
if (day.intervals.all { it.lessons.isEmpty() }) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
@@ -152,41 +155,69 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WeekChips(
private fun WeekSelector(
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 selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.clickable(enabled = true, onClick = { expanded = true })
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
selected?.let(::weekLabel) ?: "Период",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.widthIn(min = 220.dp)
) {
periods.forEach { period ->
val isTodayWeek = todayWeekStart == period.weekStart
FilterChip(
selected = selected,
onClick = { onSelect(period.weekStart) },
label = {
DropdownMenuItem(
text = {
Text(
weekLabel(period),
style = MaterialTheme.typography.labelLarge
style = MaterialTheme.typography.bodyLarge
)
},
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayWeek && !selected) {
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
trailingIcon = if (isTodayWeek) {
{
Text(
"сегодня",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} else {
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
null
},
shape = RoundedCornerShape(8.dp)
onClick = {
expanded = false
onSelect(period.weekStart)
}
)
}
}
}
}
@Composable
@@ -255,22 +286,48 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
} else {
slot.lessons.forEach { lesson ->
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
var any = false
if(lesson.type != LessonType.OTHER) {
Text(
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
style = MaterialTheme.typography.bodyLarge
)
Text(
listOfNotNull(
lesson.type.label,
lesson.subgroup,
lesson.teacher,
lesson.room
)
.filter { it.isNotBlank() }
.joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
any = true
}
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if(subject.isNotEmpty()) {
Text(
subject,
style = MaterialTheme.typography.bodyLarge
)
any = true
}
val meta = listOfNotNull(
lesson.subgroup,
lesson.teacher,
lesson.room
).filter { it.isNotBlank() }.joinToString(" · ")
if(meta.isNotEmpty()) {
Text(
meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
any = true
}
if(!any) {
Text(
"-",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -1,7 +1,6 @@
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
@@ -12,6 +11,7 @@ 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,
@@ -30,7 +30,7 @@ class ScheduleViewModel(
if (groupId != null) {
selectGroup(groupId)
} else {
_uiState.value = _uiState.value.copy(noGroup = true)
_uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
}
}
}
@@ -39,32 +39,104 @@ class ScheduleViewModel(
currentGroupId = groupId
viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId)
val groupName = groupRepository.getGroup(groupId)?.name
_uiState.value = _uiState.value.copy(noGroup = false, groupName = groupName)
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
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,
groupName = groupName,
weekStart = savedWeekStart,
dayDate = savedDayDate
)
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
)
}
currentGroupId?.let { applySchedule(schedule) }
}
}
launch {
syncIfMissingToday(groupId)
}
}
}
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 {
@@ -79,23 +151,10 @@ class ScheduleViewModel(
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
)
_uiState.value = _uiState.value.copy(isRefreshing = false)
}
}
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 {
@@ -106,29 +165,21 @@ class ScheduleViewModel(
fun clearSelectedPeriod() {
val state = _uiState.value
val schedule = state.schedule ?: return
val day = schedule.days.getOrNull(state.selectedIndex) ?: return
val period = schedule.periodOf(day.date) ?: return
val weekStart = state.weekStart ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: 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 todayIndex: Int = 0,
val selectedIndex: Int = 0
val groupName: String? = null,
val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null
)