Compare commits

...
Author SHA1 Message Date
Agzamus 708ad11dca ci: release same tag & version 2026-09-18 01:28:48 +03:00
Agzamus b1418f79da fix: immediately scrolling 2026-09-18 01:25:00 +03:00
Agzamus d17c70e871 feat: schedule swiping 2026-09-18 01:20:47 +03:00
Agzamus 6cd211f744 fix: no more group prefix 2026-09-11 02:34:30 +03:00
Agzamus 019cf5da21 feat: also refresh groups list in schedule 2026-09-11 02:05:43 +03:00
Agzamus a24748ec04 ci: "debug" if no APP_VERSION 2026-09-11 02:04:36 +03:00
Agzamus caf3debb3f feat: 2-week select 2026-09-11 01:55:52 +03:00
Agzamus 1c5140d751 fix: streaming parsing to prevent crashes on low-end phones 2026-09-11 01:34:05 +03:00
Agzamus 65bb6cc7db fix: loading instead error screen 2026-09-11 00:18:23 +03:00
Agzamus 99e05e8232 ui: surfaceContainer background 2026-09-10 01:36:24 +03:00
15 changed files with 844 additions and 148 deletions
+2 -2
View File
@@ -74,9 +74,9 @@ jobs:
SUFFIX=$((SUFFIX + 1))
TAG="v${BASE}.${SUFFIX}"
done
echo "version_name=$BASE" >> "$GITHUB_OUTPUT"
echo "version_name=$TAG" >> "$GITHUB_OUTPUT"
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Generated version: $BASE (tag: $TAG)"
echo "Generated version: $BASE"
- name: Restore keystore from secret
# Ключ подписи передаётся как base64-секрет и декодируется в файл.
+2 -1
View File
@@ -19,7 +19,7 @@ android {
versionCode = 1
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
// Локально и при отсутствии переменной — fallback "1.0".
versionName = System.getenv("APP_VERSION") ?: "1.0"
versionName = System.getenv("APP_VERSION") ?: "debug"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -53,6 +53,7 @@ android {
}
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all { it.maxHeapSize = "16m" }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
@@ -0,0 +1,427 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "3fd5eda9ac5e29fc96371ecf246e437c",
"entities": [
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "faculty",
"columnName": "faculty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "group_periods",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_group_periods_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "schedules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_schedules_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
},
{
"name": "index_schedules_groupId_season_weekStart",
"unique": true,
"columnNames": [
"groupId",
"season",
"weekStart"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "days",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleId",
"columnName": "scheduleId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_days_scheduleId",
"unique": false,
"columnNames": [
"scheduleId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
},
{
"name": "index_days_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
}
],
"foreignKeys": [
{
"table": "schedules",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"scheduleId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "lessons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayId",
"columnName": "dayId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slotIndex",
"columnName": "slotIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lessonIndex",
"columnName": "lessonIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT"
},
{
"fieldPath": "groupName",
"columnName": "groupName",
"affinity": "TEXT"
},
{
"fieldPath": "subgroup",
"columnName": "subgroup",
"affinity": "TEXT"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_lessons_dayId",
"unique": false,
"columnNames": [
"dayId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
},
{
"name": "index_lessons_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
},
{
"name": "index_lessons_subject",
"unique": false,
"columnNames": [
"subject"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
},
{
"name": "index_lessons_room",
"unique": false,
"columnNames": [
"room"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
}
],
"foreignKeys": [
{
"table": "days",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"dayId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "preferences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3fd5eda9ac5e29fc96371ecf246e437c')"
]
}
}
+1 -1
View File
@@ -23,7 +23,6 @@ fun AppRoot() {
val container = app.container
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
val state by scheduleViewModel.uiState.collectAsState()
@@ -53,6 +52,7 @@ fun AppRoot() {
}
if (state.noGroup) {
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
GroupPickerScreen(
viewModel = groupPickerViewModel,
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
@@ -7,12 +7,13 @@ import androidx.room.TypeConverters
@Database(
entities = [
GroupEntity::class,
GroupPeriodEntity::class,
ScheduleEntity::class,
DayEntity::class,
LessonEntity::class,
PreferenceEntity::class
],
version = 2,
version = 3,
exportSchema = true
)
@TypeConverters(Converters::class)
@@ -25,4 +25,13 @@ interface GroupDao {
@Query("SELECT MAX(lastSyncedAt) FROM groups")
suspend fun maxSyncedAt(): Long?
@Query("SELECT * FROM group_periods WHERE groupId = :groupId AND weekEnd >= :fromEpochDay ORDER BY weekStart")
suspend fun getCurrentAndFuturePeriods(groupId: String, fromEpochDay: Long): List<GroupPeriodEntity>
@Query("DELETE FROM group_periods")
suspend fun deleteAllPeriods()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPeriods(periods: List<GroupPeriodEntity>)
}
@@ -8,9 +8,5 @@ data class GroupEntity(
@PrimaryKey val id: String,
val name: String,
val faculty: String,
val autumnFirst: Long?,
val autumnSecond: Long?,
val springFirst: Long?,
val springSecond: Long?,
val lastSyncedAt: Long
)
@@ -0,0 +1,28 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
// Все опубликованные на сайте 2-недельные периоды группы, включая ещё не начавшиеся.
@Entity(
tableName = "group_periods",
foreignKeys = [
ForeignKey(
entity = GroupEntity::class,
parentColumns = ["id"],
childColumns = ["groupId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("groupId")]
)
data class GroupPeriodEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val groupId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long
)
@@ -3,8 +3,7 @@ package agzam.vyatsu.data.remote
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season
import android.util.Log
import org.jsoup.Jsoup
import java.io.InputStream
/**
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
@@ -14,6 +13,13 @@ object GroupsParser {
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
private val parenthesized = Regex("""\(.*?\)\s?""")
private const val CHUNK_SIZE = 8192
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
@@ -33,41 +39,118 @@ object GroupsParser {
)
/**
* Извлекает список групп с HTML страницы
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
*
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
*/
fun parseGroups(html: String): List<Group> {
val doc = Jsoup.parse(html)
val container = doc.selectFirst("div.column-center_rasp") ?: return emptyList()
val tables = container.select("table[style~=border: none !important]")
fun parseStream(input: InputStream): ParsedGroups {
val reader = input.bufferedReader(Charsets.UTF_8)
val buf = CharArray(CHUNK_SIZE)
val pending = StringBuilder(CHUNK_SIZE * 2)
val groups = ArrayList<Group>()
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
var faculty = ""
return tables.flatMap { table ->
table.select("> tbody > tr, > tr").flatMap { row ->
Log.v("ROW", row.html());
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) //
fun addRanges(text: String) {
for (m in groupPattern.findAll(text)) {
val groupId = m.groupValues[1]
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
val range = DateRange(m.groupValues[3], m.groupValues[4])
ranges.getOrPut(groupId) {
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
}.getOrPut(season) { ArrayList() }.add(range)
}
}
row.select("div.grpPeriod").map { grp ->
Group(
id = grp.attr("data-grp_period_id").dropLast(1),
name = removeParenthesized(grp.text()),
while (true) {
val n = reader.read(buf)
if (n < 0) break
pending.append(buf, 0, n)
var pos = 0
while (pos < pending.length) {
val divAt = pending.indexOf("<div", pos)
val aAt = pending.indexOf("<a", pos)
val tagStart = when {
divAt < 0 -> aAt
aAt < 0 -> divAt
else -> minOf(divAt, aAt)
}
if (tagStart < 0) {
// хвостовой текст без div-тегов — обычный текст страницы
pos = pending.length
break
}
if (tagStart > pos) {
addRanges(pending.substring(pos, tagStart))
}
val tagEnd = findTagEnd(pending, tagStart)
if (tagEnd < 0) break // тег не дочитан — ждём следующий чанк
val tagText = pending.substring(tagStart, tagEnd + 1)
val isDiv = tagText.startsWith("<div")
val isFakName = isDiv && tagText.contains("class=\"fak_name\"")
val isGrpPeriod = isDiv && tagText.contains("class=\"grpPeriod\"")
if (isFakName || isGrpPeriod) {
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
if (closeDiv < 0) break // содержимое не дочитано — ждём следующий чанк
addRanges(tagText)
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val name = content.trim()
if (name.isNotEmpty()) faculty = shortname(name)
} else {
val m = grpIdPattern.find(tagText)
if (m != null) {
groups += Group(
id = m.groupValues[1].dropLast(1),
name = removeParenthesized(content),
faculty = faculty
)
}
}
}
pos = closeDiv + 6
} else {
addRanges(tagText)
pos = tagEnd + 1
}
fun parseDateRanges(html: String): Map<String, Map<Season, List<DateRange>>> {
val result = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
groupPattern.findAll(html).forEach { m ->
val groupId = m.groupValues[1]
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
val range = DateRange(m.groupValues[3], m.groupValues[4])
result
.getOrPut(groupId) { hashMapOf(Season.AUTUMN to mutableListOf(), Season.SPRING to mutableListOf()) }
.getOrPut(season) { mutableListOf() }
.add(range)
if (pos > MAX_SLICE) {
pending.delete(0, pos)
pos = 0
}
return result
}
if (pos > 0 && pos <= pending.length) {
pending.delete(0, pos)
}
if (pending.length > MAX_PENDING) {
// на странице нет настолько длинных конструкций; страховка от аномалий
pending.delete(0, pending.length - MAX_SLICE)
}
}
return ParsedGroups(groups, ranges)
}
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
private fun findTagEnd(s: CharSequence, start: Int): Int {
var i = start
var quote = 0.toChar()
while (i < s.length) {
val c = s[i]
if (quote != 0.toChar()) {
if (c == quote) quote = 0.toChar()
} else {
when (c) {
'"', '\'' -> quote = c
'>' -> return i
}
}
i++
}
return -1
}
private fun shortname(name: String): String {
@@ -76,5 +159,10 @@ object GroupsParser {
return if (sh != null) "$sh ($stripped)" else stripped
}
private fun removeParenthesized(name: String): String = name.replace(Regex("""\(.*?\)\s?"""), "").trim()
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
}
data class ParsedGroups(
val groups: List<Group>,
val dateRanges: Map<String, Map<Season, List<DateRange>>>
)
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.InputStream
import java.util.concurrent.TimeUnit
/**
@@ -35,4 +36,15 @@ object VyatsuHttpClient {
}
}
/**
* Открывает потоковое тело ответа. Вызывающий обязан закрыть
* возвращённый [InputStream], чтобы вернуть соединение в пул.
*/
suspend fun openStream(url: String): InputStream = withContext(Dispatchers.IO) {
val resp = client.newCall(Request.Builder().url(url).build()).execute()
Log.d(TAG, "GET $url -> ${resp.code}")
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
resp.body!!.byteStream()
}
}
@@ -108,9 +108,13 @@ object PdfScheduleParser {
for (dayCell in daysColumn.cells) {
val day = content(dayCell).split(' ')
if(day.size == 1) {
Log.v(TAG, "day.first(): ${day.first()}")
}
if(day.size == 1 && day.first() == "День") {
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
group = content(groupCell)
Log.v(TAG, "Group: $group")
continue
}
if(day.size != 2) {
@@ -164,7 +168,7 @@ object PdfScheduleParser {
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,")) {
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
subject = subject.substring(group.length + 1).trim()
val subgroupMatch = SUBGROUP_REGEX.find(subject)
if(subgroupMatch != null) {
@@ -197,6 +201,7 @@ object PdfScheduleParser {
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
Log.v(SVG_TAG+ "-" + (pageIndex+1), "====")
for(line in svg.asSvg().split("\n")) {
if(line.length > 250) {
Log.v(SVG_TAG, "length: " + line.length)
@@ -2,16 +2,18 @@ package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.GroupDao
import agzam.vyatsu.data.local.GroupEntity
import agzam.vyatsu.data.local.GroupPeriodEntity
import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.repository.GroupRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class GroupRepositoryImpl(
private val groupDao: GroupDao,
@@ -40,28 +42,35 @@ class GroupRepositoryImpl(
}
private suspend fun refresh() {
val html = VyatsuHttpClient.getText(VyatsuUrls.GROUPS_PAGE)
val groups = GroupsParser.parseGroups(html)
val ranges = GroupsParser.parseDateRanges(html)
val entities = groups.map { group ->
val range = ranges[group.id]
val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
val spring = range?.get(Season.SPRING)?.lastOrNull()
val parsed = withContext(Dispatchers.IO) {
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
}
val now = now()
groupDao.insertAll(
parsed.groups.map { group ->
GroupEntity(
id = group.id,
name = group.name,
faculty = group.faculty,
autumnFirst = autumn?.first?.let(::epochDay),
autumnSecond = autumn?.second?.let(::epochDay),
springFirst = spring?.first?.let(::epochDay),
springSecond = spring?.second?.let(::epochDay),
lastSyncedAt = now()
lastSyncedAt = now
)
}
groupDao.insertAll(entities)
)
val periods = parsed.groups.flatMap { group ->
parsed.dateRanges[group.id].orEmpty().flatMap { (season, ranges) ->
ranges.map { range ->
GroupPeriodEntity(
groupId = group.id,
season = season,
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
)
}
}
}
groupDao.deleteAllPeriods()
groupDao.insertPeriods(periods)
}
private fun epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay()
private fun now(): Long = System.currentTimeMillis()
@@ -1,6 +1,7 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DayEntity
import agzam.vyatsu.data.local.GroupPeriodEntity
import agzam.vyatsu.data.local.LessonEntity
import agzam.vyatsu.data.local.ScheduleDao
import agzam.vyatsu.data.local.ScheduleEntity
@@ -51,15 +52,38 @@ class ScheduleRepositoryImpl(
Log.w(TAG, "sync: group $groupId not found in DB")
return null
}
val season = ScheduleDates.currentSeason()
val range = dateRangeFor(group, season) ?: run {
Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
val today = ScheduleDates.moscowToday().toEpochDay()
val periods = groupDao.getCurrentAndFuturePeriods(groupId, today)
if (periods.isEmpty()) {
Log.w(TAG, "sync: no current/future periods for group ${group.id}")
return null
}
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
return try {
val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range)
for (period in periods) {
syncPeriod(group, period)
}
scheduleDao.trim(groupId, MAX_PERIODS)
return getSchedule(groupId)
}
private suspend fun syncPeriod(group: GroupEntity, period: GroupPeriodEntity) {
val season = period.season
val range = DateRange(
LocalDate.ofEpochDay(period.weekStart).format(RANGE_FMT),
LocalDate.ofEpochDay(period.weekEnd).format(RANGE_FMT)
)
val existing = scheduleDao.getSchedules(group.id)
.firstOrNull { it.season == season && it.weekStart == period.weekStart }
val isCurrent = ScheduleDates.moscowToday().toEpochDay() in period.weekStart..period.weekEnd
if (existing != null && !isCurrent && now() - existing.lastSyncedAt < STALE_WINDOW_MS) {
Log.d(TAG, "sync: period $range up to date, skip")
return
}
try {
val pdfUrl = VyatsuUrls.groupPdfUrl(group.id, season, range)
Log.d(TAG, "sync: downloading $pdfUrl")
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
@@ -67,29 +91,19 @@ class ScheduleRepositoryImpl(
val days = PdfScheduleParser.parse(bytes)
Log.d(TAG, "sync: parsed ${days.size} days")
val weekStart = ScheduleDates.asDate(range.first).toEpochDay()
val weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
val existing = scheduleDao.getSchedules(groupId)
.firstOrNull { it.season == season && it.weekStart == weekStart }
val scheduleId = if (existing != null) existing.id
else scheduleDao.insertSchedule(
val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
ScheduleEntity(
groupId = groupId,
groupId = group.id,
season = season,
weekStart = weekStart,
weekEnd = weekEnd,
weekStart = period.weekStart,
weekEnd = period.weekEnd,
lastSyncedAt = now()
)
)
replaceDays(scheduleId, days)
scheduleDao.trim(groupId, MAX_PERIODS)
getSchedule(groupId)
Log.d(TAG, "sync: period $range synced")
} catch (e: Throwable) {
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
null
Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
}
}
@@ -187,30 +201,13 @@ class ScheduleRepositoryImpl(
teacher = teacher
)
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
return when (season) {
Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) {
DateRange(
LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT)
)
} else null
Season.SPRING -> if (group.springFirst != null && group.springSecond != null) {
DateRange(
LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT),
LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT)
)
} else null
}
}
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun now(): Long = System.currentTimeMillis()
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
private const val MAX_PERIODS = 3
private const val MAX_PERIODS = 24
private const val TAG = "ScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
}
@@ -18,15 +18,22 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
@@ -41,25 +48,33 @@ import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.collectLatest
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
val hPadding = 16.dp
@@ -74,6 +89,12 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
TopAppBar(
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
actions = {
if (state.isLoading && state.schedule != null) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
TextButton(onClick = { onSwitchGroup() }) {
Text("Сменить группу")
}
@@ -85,16 +106,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
)
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
when {
state.isSyncing && state.schedule == null -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
state.schedule == null -> {
state.schedule == null && state.loadError -> {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
@@ -109,6 +121,15 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
}
state.schedule == null -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
else -> {
val schedule = state.schedule!!
val weekDays = state.weekStart
@@ -120,6 +141,32 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
val schedule = state.schedule!!
val weekDays = state.weekStart
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
?.let { schedule.daysOf(it) }
.orEmpty()
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
val pagerState = rememberPagerState(
initialPage = selectedDayIndex,
pageCount = { weekDays.size }
)
LaunchedEffect(state.dayDate) {
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
if (targetPage != -1 && targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage) // Мгновенное переключение без анимации
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (pagerState.isScrollInProgress && page in weekDays.indices) {
viewModel.selectDay(weekDays[page].date)
}
}
}
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
@@ -145,16 +192,25 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
onSelect = viewModel::selectDay
onSelect = viewModel::selectDay,
pagerState = pagerState
)
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
if (day.intervals.all { it.lessons.isEmpty() }) {
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
val day = weekDays.getOrNull(page)
if (day == null || day.intervals.all { it.lessons.isEmpty() }) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
@@ -169,6 +225,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -235,14 +292,70 @@ private fun WeekSelector(
}
}
private fun getVisiblePercentage(item: LazyListItemInfo, layoutInfo: LazyListLayoutInfo): Float {
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
val itemStart = item.offset
val itemEnd = item.offset + item.size
val visibleStart = max(itemStart, viewportStart)
val visibleEnd = min(itemEnd, viewportEnd)
val visibleSize = visibleEnd - visibleStart
return if (visibleSize <= 0) 0f else visibleSize.toFloat() / item.size
}
@Composable
private fun DayChips(
days: List<Day>,
selectedDate: LocalDate?,
todayDate: LocalDate?,
pagerState: PagerState,
onSelect: (LocalDate) -> Unit
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
val listState = rememberLazyListState()
val density = LocalDensity.current
val paddingPx = with(density) { hPadding.toPx() }
LaunchedEffect(pagerState, days) {
snapshotFlow {
pagerState.currentPage
}.collectLatest { targetPage ->
if (targetPage in days.indices) {
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val targetItem = visibleItems.firstOrNull { it.index == targetPage }
if (targetItem != null) {
val visiblePercentage = getVisiblePercentage(targetItem, layoutInfo)
val minVisibleThreshold = 0.9f
if (visiblePercentage < minVisibleThreshold) {
val viewPortWidth = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val halfViewport = viewPortWidth / 2f
val centerOffset = (halfViewport - (targetItem.size / 2f) - paddingPx).toInt()
listState.animateScrollToItem(index = targetPage, scrollOffset = -centerOffset)
}
} else {
val viewPortWidth = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val halfViewport = viewPortWidth / 2f
val averageSize = visibleItems.map { it.size }.average().toInt().coerceAtLeast(150)
val centerOffset = (halfViewport - (averageSize / 2f) - paddingPx).toInt()
listState.animateScrollToItem(index = targetPage, scrollOffset = -centerOffset)
}
}
}
}
LazyRow(state = listState, horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date
@@ -310,13 +423,11 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
Text("—", style = MaterialTheme.typography.bodyMedium)
} else {
slot.lessons.forEachIndexed { index, lesson ->
val baseSurface = MaterialTheme.colorScheme.surfaceContainer
val backgroundColor = if (indexColor % 2 == 0) {
baseSurface
} else {
baseSurface.copy(alpha = 1f).compositeOver(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.03f)
)
var baseSurface = Modifier.fillMaxWidth().padding(vertical = 2.dp)
if ((indexColor) % 2 != 0) {
baseSurface = baseSurface.background(
MaterialTheme.colorScheme.surfaceContainer
);
}
indexColor++
// XXX:
@@ -326,7 +437,7 @@ private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
// Меню на onLongClick?
SelectionContainer() {
Column(
Modifier.fillMaxWidth().padding(vertical = 2.dp).background(backgroundColor)
baseSurface
// XXX: combinedClickable?
//.combinedClickable(
// onClick = {
@@ -37,6 +37,7 @@ class ScheduleViewModel(
fun selectGroup(groupId: String) {
currentGroupId = groupId
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId)
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
@@ -61,7 +62,7 @@ class ScheduleViewModel(
}
launch {
syncIfMissingToday(groupId)
syncIfMissing(groupId)
}
}
}
@@ -96,7 +97,9 @@ class ScheduleViewModel(
schedule = schedule,
weekStart = weekStart,
dayDate = dayDate,
todayDate = todayDate
todayDate = todayDate,
isLoading = false,
loadError = false
)
}
@@ -128,30 +131,39 @@ class ScheduleViewModel(
}
}
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)
private suspend fun syncIfMissing(groupId: String) {
if (scheduleRepository.getSchedule(groupId) != null) {
_uiState.value = _uiState.value.copy(isLoading = false)
return
}
loadSchedule(groupId)
}
private suspend fun loadSchedule(groupId: String) {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
val schedule = scheduleRepository.sync(groupId)
_uiState.value = if (schedule != null) {
_uiState.value.copy(isLoading = false, loadError = false)
} else {
_uiState.value.copy(isLoading = false, loadError = true)
}
}
fun switchGroup() {
viewModelScope.launch {
currentGroupId = null
preferencesRepository.clearSelectedGroup()
_uiState.value = _uiState.value.copy(noGroup = true)
_uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
}
}
fun refresh() {
val groupId = currentGroupId ?: return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
scheduleRepository.sync(groupId)
_uiState.value = _uiState.value.copy(isRefreshing = false)
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
runCatching { groupRepository.forceRefresh() }
loadSchedule(groupId)
}
}
@@ -175,8 +187,8 @@ class ScheduleViewModel(
data class ScheduleUiState(
val noGroup: Boolean = false,
val isRefreshing: Boolean = false,
val isSyncing: Boolean = false,
val isLoading: Boolean = false,
val loadError: Boolean = false,
val schedule: Schedule? = null,
val groupName: String? = null,
val weekStart: LocalDate? = null,