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 - 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"
} }
@@ -38,7 +38,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
private val svgBuilder = StringBuilder() private val svgBuilder = StringBuilder()
private val currentPath = StringBuilder() private val currentPath = StringBuilder()
private val tableSvg = StringBuilder() val tableSvg = StringBuilder()
private val currentSubPathLines = mutableListOf<PdfLine>() private val currentSubPathLines = mutableListOf<PdfLine>()
@@ -52,7 +52,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
val width = mediaBox.width val width = mediaBox.width
val height = mediaBox.height val height = mediaBox.height
return """ 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>${svgBuilder}</g>
<g>${tableSvg}</g> <g>${tableSvg}</g>
</svg> </svg>
@@ -211,7 +211,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
val bottom = ys[ii+1] val bottom = ys[ii+1]
cells.add(TableCell(left, top, right-left, bottom-top)) 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)) 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.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 ============")
@@ -45,12 +52,6 @@ object PdfScheduleParser {
svg.processPage(page) svg.processPage(page)
val cols = svg.asTable() 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") if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
// Парсинг текста // Парсинг текста
@@ -60,6 +61,11 @@ object PdfScheduleParser {
textStripper.getText(doc) textStripper.getText(doc)
val texts = textStripper.blocks 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 daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm" val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
@@ -100,10 +106,17 @@ 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 != 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 date = dateFrom(day[1]) ?: continue
val intervals = mutableListOf<TimeSlot>() val intervals = mutableListOf<TimeSlot>()
@@ -124,7 +137,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))
} }
@@ -133,6 +194,18 @@ object PdfScheduleParser {
for (text in texts.filter { it.rotation != 0f }) { for (text in texts.filter { it.rotation != 0f }) {
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}") 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) { } catch (e: Exception) {
Log.e(TAG, "Parsing error", e) Log.e(TAG, "Parsing error", e)
@@ -7,6 +7,7 @@ import com.tom_roush.pdfbox.text.TextPosition
* Парсер блоков текста из PDF * Парсер блоков текста из PDF
* @author Agzam * @author Agzam
*/ */
class PdfTextParser : PDFTextStripper() { class PdfTextParser : PDFTextStripper() {
data class TextBlock( data class TextBlock(
@@ -25,21 +26,51 @@ class PdfTextParser : PDFTextStripper() {
} }
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) { override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) { if (textPositions.isNullOrEmpty()) return
val first = textPositions.first()
val last = textPositions.last()
val matrix = first.textMatrix val currentWordChars = mutableListOf<TextPosition>()
val rotation = first.dir
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj for (tp in textPositions) {
val unicode = tp.unicode
val x = matrix.translateX if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
val y = matrix.translateY - first.heightDir addWordBlock(currentWordChars)
currentWordChars.clear()
val height = first.heightDir } else {
currentWordChars.add(tp)
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation)) }
} }
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
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
val x = matrix.translateX
val y = matrix.translateY - first.heightDir
val height = first.heightDir
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> = 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 {
@@ -157,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 }
@@ -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
@@ -1,9 +1,12 @@
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.LessonType
import agzam.vyatsu.domain.model.SchedulePeriod 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
@@ -12,14 +15,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.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
@@ -33,8 +40,12 @@ 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.LocalDate
@@ -98,31 +109,23 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
val isTodayDay = day.date == state.todayDate val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " 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))
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( DayChips(
days = weekDays, days = weekDays,
@@ -131,7 +134,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
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)
@@ -152,39 +155,67 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun WeekChips( private fun WeekSelector(
periods: List<SchedulePeriod>, periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?, selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?, todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit onSelect: (LocalDate) -> Unit
) { ) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
items(periods, key = { it.weekStart.toEpochDay() }) { period -> var expanded by remember { mutableStateOf(false) }
val selected = selectedWeekStart == period.weekStart
val isTodayWeek = todayWeekStart == period.weekStart Box {
FilterChip( Row(
selected = selected, modifier = Modifier
onClick = { onSelect(period.weekStart) }, .clip(MaterialTheme.shapes.small)
label = { .clickable(enabled = true, onClick = { expanded = true })
Text( .padding(horizontal = 10.dp, vertical = 8.dp),
weekLabel(period), verticalAlignment = Alignment.CenterVertically
style = MaterialTheme.typography.labelLarge ) {
) Text(
}, selected?.let(::weekLabel) ?: "Период",
modifier = Modifier.padding(vertical = 2.dp), style = MaterialTheme.typography.labelLarge,
border = if (isTodayWeek && !selected) { color = MaterialTheme.colorScheme.primary
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
)
} else {
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
},
shape = RoundedCornerShape(8.dp)
) )
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)
}
)
}
} }
} }
} }
@@ -255,22 +286,48 @@ 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( var any = false
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw, if(lesson.type != LessonType.OTHER) {
style = MaterialTheme.typography.bodyLarge Text(
)
Text(
listOfNotNull(
lesson.type.label, lesson.type.label,
lesson.subgroup, style = MaterialTheme.typography.bodySmall,
lesson.teacher, color = MaterialTheme.colorScheme.onSurfaceVariant
lesson.room
) )
.filter { it.isNotBlank() } any = true
.joinToString(" · "), }
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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 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
) )