Compare commits

..
14 Commits
Author SHA1 Message Date
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
Agzamus 1fc43bb8fc refactor: clenup 2026-09-03 20:54:44 +03:00
Agzamus 32f93197ab feat: week select 2026-09-03 20:53:17 +03:00
Agzamus 32d0cec8ce feat: removed auto sync 2026-09-03 20:46:11 +03:00
Agzamus 72e63addb2 fix: FOREIGN KEY constraint failed 2026-09-03 20:41:03 +03:00
13 changed files with 319 additions and 89 deletions
+3
View File
@@ -85,6 +85,9 @@ jobs:
- name: Build release APK - name: Build release APK
run: ./gradlew assembleRelease run: ./gradlew assembleRelease
env: env:
# Версия приложения — дата, сгенерированная на предыдущем шаге.
# Читается в app/build.gradle.kts как versionName (для BuildConfig).
APP_VERSION: ${{ steps.version.outputs.version_name }}
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts # Переменные окружения, которые читает signingConfig в app/build.gradle.kts
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+3 -1
View File
@@ -17,7 +17,9 @@ android {
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 1
versionName = "1.0" // В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
// Локально и при отсутствии переменной — fallback "1.0".
versionName = System.getenv("APP_VERSION") ?: "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -15,4 +15,16 @@ interface PreferencesDao {
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'") @Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
suspend fun clearSelectedGroup() 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) suspend fun trim(groupId: String, keep: Int)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDays(days: List<DayEntity>) suspend fun insertDays(days: List<DayEntity>): List<Long>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLessons(lessons: List<LessonEntity>) suspend fun insertLessons(lessons: List<LessonEntity>)
@@ -2,6 +2,7 @@ package agzam.vyatsu.data.remote.pdf
import agzam.vyatsu.domain.model.Day import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.TimeSlot import agzam.vyatsu.domain.model.TimeSlot
import android.util.Log import android.util.Log
import com.tom_roush.pdfbox.pdmodel.PDDocument 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 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 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> { fun parse(pdfBytes: ByteArray): List<Day> {
Log.d(TAG, "Parsing ${pdfBytes.size} bytes") Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
val doc = try { val doc = try {
@@ -35,6 +40,8 @@ object PdfScheduleParser {
} }
val days = mutableListOf<Day>() val days = mutableListOf<Day>()
var group: String? = null
try { try {
for (pageIndex in 0 until doc.numberOfPages) { for (pageIndex in 0 until doc.numberOfPages) {
Log.v(TAG, "============ Page $pageIndex ============") Log.v(TAG, "============ Page $pageIndex ============")
@@ -100,9 +107,13 @@ object PdfScheduleParser {
} }
} }
for (dayCell in daysColumn.cells) { for (dayCell in daysColumn.cells) {
val day = content(dayCell).split(' ') val day = content(dayCell).split(' ')
if(day.size == 1 && day.first() == "День") {
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
group = content(groupCell)
continue
}
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска? if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
val date = dateFrom(day[1]) ?: continue val date = dateFrom(day[1]) ?: continue
@@ -124,7 +135,55 @@ object PdfScheduleParser {
val content = content(lessonCell) val content = content(lessonCell)
Log.v(TAG, "- $content") Log.v(TAG, "- $content")
// TODO: парсинг контента лекции // 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)) intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
} }
@@ -23,9 +23,6 @@ class GroupRepositoryImpl(
override suspend fun getGroups(): List<Group> = override suspend fun getGroups(): List<Group> =
groupDao.observeAll().first().map { it.toDomain() } 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 getFaculties(): List<String> = groupDao.getFaculties()
override suspend fun refreshIfStale(): Boolean { override suspend fun refreshIfStale(): Boolean {
@@ -9,4 +9,8 @@ class PreferencesRepositoryImpl(
override suspend fun selectedGroupId(): String? = dao.selectedGroupId() override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId) override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup() 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,11 +112,8 @@ class ScheduleRepositoryImpl(
private suspend fun replaceDays(scheduleId: Long, days: List<Day>) { private suspend fun replaceDays(scheduleId: Long, days: List<Day>) {
scheduleDao.deleteDaysForSchedule(scheduleId) scheduleDao.deleteDaysForSchedule(scheduleId)
val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) } val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) }
val dayIdByDate = mutableMapOf<LocalDate, Long>() val dayIds = scheduleDao.insertDays(dayEntities)
scheduleDao.insertDays(dayEntities) val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) -> LocalDate.ofEpochDay(day.date) to id }
for (day in dayEntities) {
dayIdByDate[LocalDate.ofEpochDay(day.date)] = day.id
}
val lessons = days.flatMap { day -> val lessons = days.flatMap { day ->
day.intervals.flatMapIndexed { slotIndex, slot -> day.intervals.flatMapIndexed { slotIndex, slot ->
slot.lessons.mapIndexed { lessonIndex, lesson -> slot.lessons.mapIndexed { lessonIndex, lesson ->
@@ -160,6 +157,7 @@ class ScheduleRepositoryImpl(
val lessonsByDay = lessonEntities.groupBy { it.dayId } val lessonsByDay = lessonEntities.groupBy { it.dayId }
val days = dayEntities val days = dayEntities
.sortedBy { it.date } .sortedBy { it.date }
.distinctBy { it.date }
.map { day -> .map { day ->
val lessons = lessonsByDay[day.id].orEmpty() val lessons = lessonsByDay[day.id].orEmpty()
.groupBy { it.slotIndex } .groupBy { it.slotIndex }
@@ -15,4 +15,7 @@ data class Schedule(
) { ) {
fun periodOf(date: LocalDate): SchedulePeriod? = fun periodOf(date: LocalDate): SchedulePeriod? =
periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) } 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,7 +6,6 @@ import kotlinx.coroutines.flow.Flow
interface GroupRepository { interface GroupRepository {
fun observeGroups(): Flow<List<Group>> fun observeGroups(): Flow<List<Group>>
suspend fun getGroups(): List<Group> suspend fun getGroups(): List<Group>
suspend fun getGroup(id: String): Group?
suspend fun getFaculties(): List<String> suspend fun getFaculties(): List<String>
suspend fun refreshIfStale(): Boolean suspend fun refreshIfStale(): Boolean
suspend fun forceRefresh(): Boolean suspend fun forceRefresh(): Boolean
@@ -4,4 +4,8 @@ interface PreferencesRepository {
suspend fun selectedGroupId(): String? suspend fun selectedGroupId(): String?
suspend fun setSelectedGroup(groupId: String) suspend fun setSelectedGroup(groupId: String)
suspend fun clearSelectedGroup() suspend fun clearSelectedGroup()
suspend fun selectedWeekStart(): String?
suspend fun setSelectedWeekStart(weekStart: String)
suspend fun selectedDayDate(): String?
suspend fun setSelectedDayDate(date: String)
} }
@@ -1,8 +1,11 @@
package agzam.vyatsu.ui.schedule package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -11,13 +14,18 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
@@ -31,10 +39,15 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -55,7 +68,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}, },
windowInsets = WindowInsets(0.dp) 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 { when {
state.isSyncing && state.schedule == null -> { state.isSyncing && state.schedule == null -> {
Row( Row(
@@ -83,38 +96,44 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
else -> { else -> {
val schedule = state.schedule!! val schedule = state.schedule!!
val day = schedule.days.getOrNull(state.selectedIndex) 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 }
if (day == null) { if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge) Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else { } else {
val isToday = state.selectedIndex == state.todayIndex val isTodayDay = day.date == state.todayDate
val dayLabel = if (isToday) "Сегодня " else "" val dayLabel = if (isTodayDay) "Сегодня " else ""
Text(
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium
)
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) verticalAlignment = Alignment.CenterVertically
) { ) {
TextButton(onClick = { viewModel.clearSelectedPeriod() }) { Text(
Text("Очистить неделю") "$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
} style = MaterialTheme.typography.titleMedium,
TextButton(onClick = { viewModel.clearAll() }) { modifier = Modifier.weight(1f)
Text("Очистить всё") )
} 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( DayChips(
days = schedule.days, days = weekDays,
selectedIndex = state.selectedIndex, selectedDate = state.dayDate,
todayIndex = state.todayIndex, todayDate = state.todayDate,
onSelect = viewModel::selectDay 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() }) { if (day.intervals.all { it.lessons.isEmpty() }) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge) Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
@@ -135,20 +154,85 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WeekSelector(
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
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
DropdownMenuItem(
text = {
Text(
weekLabel(period),
style = MaterialTheme.typography.bodyLarge
)
},
trailingIcon = if (isTodayWeek) {
{
Text(
"сегодня",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} else {
null
},
onClick = {
expanded = false
onSelect(period.weekStart)
}
)
}
}
}
}
@Composable @Composable
private fun DayChips( private fun DayChips(
days: List<Day>, days: List<Day>,
selectedIndex: Int, selectedDate: LocalDate?,
todayIndex: Int, todayDate: LocalDate?,
onSelect: (Int) -> Unit onSelect: (LocalDate) -> Unit
) { ) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
itemsIndexed(days) { index, day -> items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedIndex == index val selected = selectedDate == day.date
val isTodayChip = todayIndex == index val isTodayChip = todayDate == day.date
FilterChip( FilterChip(
selected = selected, selected = selected,
onClick = { onSelect(index) }, onClick = { onSelect(day.date) },
label = { label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text( Text(
@@ -183,6 +267,11 @@ private fun DayChips(
} }
} }
private fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@Composable @Composable
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) { private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
@@ -196,13 +285,22 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
} else { } else {
slot.lessons.forEach { lesson -> slot.lessons.forEach { lesson ->
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
lesson.type.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text( Text(
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw, lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
Text( Text(
listOf(lesson.type.label, lesson.subgroup, lesson.teacher, lesson.room) listOfNotNull(
.filterNotNull() lesson.subgroup,
lesson.teacher,
lesson.room
)
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.joinToString(" · "), .joinToString(" · "),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -226,4 +324,4 @@ private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM") private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
@@ -1,7 +1,6 @@
package agzam.vyatsu.ui.schedule package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.ScheduleDates import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Schedule import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.repository.GroupRepository import agzam.vyatsu.domain.repository.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository import agzam.vyatsu.domain.repository.PreferencesRepository
@@ -12,6 +11,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate
class ScheduleViewModel( class ScheduleViewModel(
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: ScheduleRepository,
@@ -30,7 +30,7 @@ class ScheduleViewModel(
if (groupId != null) { if (groupId != null) {
selectGroup(groupId) selectGroup(groupId)
} else { } else {
_uiState.value = _uiState.value.copy(noGroup = true) _uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
} }
} }
} }
@@ -39,33 +39,105 @@ class ScheduleViewModel(
currentGroupId = groupId currentGroupId = groupId
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId) preferencesRepository.setSelectedGroup(groupId)
val groupName = groupRepository.getGroup(groupId)?.name val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
_uiState.value = _uiState.value.copy(noGroup = false, groupName = groupName)
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 { launch {
scheduleRepository.observeSchedule(groupId).collect { schedule -> scheduleRepository.observeSchedule(groupId).collect { schedule ->
currentGroupId?.let { currentGroupId?.let { applySchedule(schedule) }
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 { launch {
_uiState.value = _uiState.value.copy(isSyncing = true) syncIfMissingToday(groupId)
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() { fun switchGroup() {
viewModelScope.launch { viewModelScope.launch {
currentGroupId = null currentGroupId = null
@@ -79,23 +151,10 @@ class ScheduleViewModel(
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true) _uiState.value = _uiState.value.copy(isRefreshing = true)
scheduleRepository.sync(groupId) scheduleRepository.sync(groupId)
val current = _uiState.value _uiState.value = _uiState.value.copy(isRefreshing = false)
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() { fun clearAll() {
val groupId = currentGroupId ?: return val groupId = currentGroupId ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -106,29 +165,21 @@ class ScheduleViewModel(
fun clearSelectedPeriod() { fun clearSelectedPeriod() {
val state = _uiState.value val state = _uiState.value
val schedule = state.schedule ?: return val schedule = state.schedule ?: return
val day = schedule.days.getOrNull(state.selectedIndex) ?: return val weekStart = state.weekStart ?: return
val period = schedule.periodOf(day.date) ?: return val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
viewModelScope.launch { viewModelScope.launch {
scheduleRepository.clearPeriod(schedule.groupId, period) 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( data class ScheduleUiState(
val noGroup: Boolean = false, val noGroup: Boolean = false,
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val groupName: String? = null,
val schedule: Schedule? = null, val schedule: Schedule? = null,
val todayIndex: Int = 0, val groupName: String? = null,
val selectedIndex: Int = 0 val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null
) )