Compare commits

...
8 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
8 changed files with 238 additions and 97 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"
} }
+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 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) } private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
@@ -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
)
)
} }
} }
@@ -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,26 +15,25 @@ 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.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults.TrailingIcon
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -43,6 +45,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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
@@ -55,7 +58,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
TopAppBar( TopAppBar(
title = { Text("Расписание") }, title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
actions = { actions = {
TextButton(onClick = { onSwitchGroup() }) { TextButton(onClick = { onSwitchGroup() }) {
Text("Сменить группу") Text("Сменить группу")
@@ -64,7 +67,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить") 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 { when {
@@ -106,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))
WeekSelector(
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,
@@ -139,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)
@@ -171,43 +166,49 @@ private fun WeekSelector(
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart } val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox( Box {
expanded = expanded, Row(
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = selected?.let(::weekLabel) ?: "Выберите период",
onValueChange = {},
readOnly = true,
label = { Text("Период") },
singleLine = true,
trailingIcon = { TrailingIcon(expanded = expanded) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .clip(MaterialTheme.shapes.small)
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true) .clickable(enabled = true, onClick = { expanded = true })
) .padding(horizontal = 10.dp, vertical = 8.dp),
ExposedDropdownMenu( 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, expanded = expanded,
onDismissRequest = { expanded = false } onDismissRequest = { expanded = false },
modifier = Modifier.widthIn(min = 220.dp)
) { ) {
periods.forEach { period -> periods.forEach { period ->
val isTodayWeek = todayWeekStart == period.weekStart val isTodayWeek = todayWeekStart == period.weekStart
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Row(verticalAlignment = Alignment.CenterVertically) { Text(
weekLabel(period),
style = MaterialTheme.typography.bodyLarge
)
},
trailingIcon = if (isTodayWeek) {
{
Text( Text(
weekLabel(period), "сегодня",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.labelMedium,
modifier = Modifier.weight(1f) color = MaterialTheme.colorScheme.primary
) )
if (isTodayWeek) {
Text(
"сегодня",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} }
} else {
null
}, },
onClick = { onClick = {
expanded = false expanded = false
@@ -285,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
)
}
} }
} }
} }
@@ -2,6 +2,7 @@ package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.ScheduleDates import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Schedule import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.repository.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository import agzam.vyatsu.domain.repository.ScheduleRepository
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -14,7 +15,8 @@ import java.time.LocalDate
class ScheduleViewModel( class ScheduleViewModel(
private val scheduleRepository: ScheduleRepository, private val scheduleRepository: ScheduleRepository,
private val preferencesRepository: PreferencesRepository private val preferencesRepository: PreferencesRepository,
private val groupRepository: GroupRepository
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ScheduleUiState()) private val _uiState = MutableStateFlow(ScheduleUiState())
@@ -28,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)
} }
} }
} }
@@ -37,6 +39,7 @@ class ScheduleViewModel(
currentGroupId = groupId currentGroupId = groupId
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId) preferencesRepository.setSelectedGroup(groupId)
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
val savedWeekStart = runCatching { val savedWeekStart = runCatching {
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse) preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
@@ -46,6 +49,7 @@ class ScheduleViewModel(
}.getOrNull() }.getOrNull()
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
noGroup = false, noGroup = false,
groupName = groupName,
weekStart = savedWeekStart, weekStart = savedWeekStart,
dayDate = savedDayDate dayDate = savedDayDate
) )
@@ -174,6 +178,7 @@ data class ScheduleUiState(
val isRefreshing: Boolean = false, val isRefreshing: Boolean = false,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
val schedule: Schedule? = null, val schedule: Schedule? = null,
val groupName: String? = null,
val weekStart: LocalDate? = null, val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null, val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null val todayDate: LocalDate? = null