Compare commits

...
6 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
6 changed files with 136 additions and 62 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) }
@@ -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))
} }
@@ -3,7 +3,9 @@ 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.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 +14,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 +44,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 +57,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 +66,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 +108,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 +133,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 +165,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,13 +285,18 @@ 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(
listOfNotNull( listOfNotNull(
lesson.type.label,
lesson.subgroup, lesson.subgroup,
lesson.teacher, lesson.teacher,
lesson.room lesson.room
@@ -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