Compare commits

...
18 changed files with 851 additions and 155 deletions
+2 -2
View File
@@ -74,9 +74,9 @@ jobs:
SUFFIX=$((SUFFIX + 1)) SUFFIX=$((SUFFIX + 1))
TAG="v${BASE}.${SUFFIX}" TAG="v${BASE}.${SUFFIX}"
done done
echo "version_name=$BASE" >> "$GITHUB_OUTPUT" echo "version_name=$TAG" >> "$GITHUB_OUTPUT"
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT" echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Generated version: $BASE (tag: $TAG)" echo "Generated version: $BASE"
- name: Restore keystore from secret - name: Restore keystore from secret
# Ключ подписи передаётся как base64-секрет и декодируется в файл. # Ключ подписи передаётся как base64-секрет и декодируется в файл.
+4 -2
View File
@@ -19,7 +19,7 @@ android {
versionCode = 1 versionCode = 1
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД). // В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
// Локально и при отсутствии переменной — fallback "1.0". // Локально и при отсутствии переменной — fallback "1.0".
versionName = System.getenv("APP_VERSION") ?: "1.0" versionName = System.getenv("APP_VERSION") ?: "debug"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -40,7 +40,8 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@@ -53,6 +54,7 @@ android {
} }
testOptions { testOptions {
unitTests.isReturnDefaultValues = true unitTests.isReturnDefaultValues = true
unitTests.all { it.maxHeapSize = "16m" }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
+9
View File
@@ -19,3 +19,12 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
# Игнорировать отсутствие класса декодера JP2 в PdfBox-Android
-dontwarn com.gemalto.jp2.JP2Decoder
# На всякий случай, если PdfBox ссылается на другие отсутствующие библиотеки (например, ICU для текста)
-dontwarn com.tom_roush.pdfbox.**
# Запретить R8 удалять и переименовывать внутренности PdfBox-Android
-keep class com.tom_roush.pdfbox.** { *; }
-keep interface com.tom_roush.pdfbox.** { *; }
@@ -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 container = app.container
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container)) val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container)) val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
val state by scheduleViewModel.uiState.collectAsState() val state by scheduleViewModel.uiState.collectAsState()
@@ -53,6 +52,7 @@ fun AppRoot() {
} }
if (state.noGroup) { if (state.noGroup) {
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
GroupPickerScreen( GroupPickerScreen(
viewModel = groupPickerViewModel, viewModel = groupPickerViewModel,
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) } onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
@@ -7,12 +7,13 @@ import androidx.room.TypeConverters
@Database( @Database(
entities = [ entities = [
GroupEntity::class, GroupEntity::class,
GroupPeriodEntity::class,
ScheduleEntity::class, ScheduleEntity::class,
DayEntity::class, DayEntity::class,
LessonEntity::class, LessonEntity::class,
PreferenceEntity::class PreferenceEntity::class
], ],
version = 2, version = 3,
exportSchema = true exportSchema = true
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -25,4 +25,13 @@ interface GroupDao {
@Query("SELECT MAX(lastSyncedAt) FROM groups") @Query("SELECT MAX(lastSyncedAt) FROM groups")
suspend fun maxSyncedAt(): Long? 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, @PrimaryKey val id: String,
val name: String, val name: String,
val faculty: String, val faculty: String,
val autumnFirst: Long?,
val autumnSecond: Long?,
val springFirst: Long?,
val springSecond: Long?,
val lastSyncedAt: 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.DateRange
import agzam.vyatsu.domain.model.Group import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season import agzam.vyatsu.domain.model.Season
import android.util.Log import java.io.InputStream
import org.jsoup.Jsoup
/** /**
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py * Основано на 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 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( private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ", "Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк", "Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
@@ -33,41 +39,118 @@ object GroupsParser {
) )
/** /**
* Извлекает список групп с HTML страницы * Стриминговый парсер страницы групп: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
*
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
*/ */
fun parseGroups(html: String): List<Group> { fun parseStream(input: InputStream): ParsedGroups {
val doc = Jsoup.parse(html) val reader = input.bufferedReader(Charsets.UTF_8)
val container = doc.selectFirst("div.column-center_rasp") ?: return emptyList() val buf = CharArray(CHUNK_SIZE)
val tables = container.select("table[style~=border: none !important]") val pending = StringBuilder(CHUNK_SIZE * 2)
val groups = ArrayList<Group>()
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
var faculty = ""
return tables.flatMap { table -> fun addRanges(text: String) {
table.select("> tbody > tr, > tr").flatMap { row -> for (m in groupPattern.findAll(text)) {
Log.v("ROW", row.html()); val groupId = m.groupValues[1]
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) // 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 -> while (true) {
Group( val n = reader.read(buf)
id = grp.attr("data-grp_period_id").dropLast(1), if (n < 0) break
name = removeParenthesized(grp.text()), 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 faculty = faculty
) )
} }
} }
} pos = closeDiv + 6
} else {
addRanges(tagText)
pos = tagEnd + 1
} }
fun parseDateRanges(html: String): Map<String, Map<Season, List<DateRange>>> { if (pos > MAX_SLICE) {
val result = HashMap<String, HashMap<Season, MutableList<DateRange>>>() pending.delete(0, pos)
groupPattern.findAll(html).forEach { m -> pos = 0
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)
} }
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 { private fun shortname(name: String): String {
@@ -76,5 +159,10 @@ object GroupsParser {
return if (sh != null) "$sh ($stripped)" else stripped 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 kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.io.InputStream
import java.util.concurrent.TimeUnit 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) { for (dayCell in daysColumn.cells) {
val day = content(dayCell).split(' ') val day = content(dayCell).split(' ')
if(day.size == 1) {
Log.v(TAG, "day.first(): ${day.first()}")
}
if(day.size == 1 && day.first() == "День") { if(day.size == 1 && day.first() == "День") {
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
group = content(groupCell) group = content(groupCell)
Log.v(TAG, "Group: $group")
continue continue
} }
if(day.size != 2) { if(day.size != 2) {
@@ -164,7 +168,7 @@ object PdfScheduleParser {
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim() val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
var subgroup: String? = null var subgroup: String? = null
Log.d(TAG, "$subject $group") 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() subject = subject.substring(group.length + 1).trim()
val subgroupMatch = SUBGROUP_REGEX.find(subject) val subgroupMatch = SUBGROUP_REGEX.find(subject)
if(subgroupMatch != null) { if(subgroupMatch != null) {
@@ -197,6 +201,7 @@ object PdfScheduleParser {
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/ /** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====") Log.v(SVG_TAG, "====")
Log.v(SVG_TAG+ "-" + (pageIndex+1), "====")
for(line in svg.asSvg().split("\n")) { for(line in svg.asSvg().split("\n")) {
if(line.length > 250) { if(line.length > 250) {
Log.v(SVG_TAG, "length: " + line.length) 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.GroupDao
import agzam.vyatsu.data.local.GroupEntity import agzam.vyatsu.data.local.GroupEntity
import agzam.vyatsu.data.local.GroupPeriodEntity
import agzam.vyatsu.data.remote.GroupsParser import agzam.vyatsu.data.remote.GroupsParser
import agzam.vyatsu.data.remote.VyatsuHttpClient import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Group import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.repository.GroupRepository import agzam.vyatsu.domain.repository.GroupRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class GroupRepositoryImpl( class GroupRepositoryImpl(
private val groupDao: GroupDao, private val groupDao: GroupDao,
@@ -40,28 +42,35 @@ class GroupRepositoryImpl(
} }
private suspend fun refresh() { private suspend fun refresh() {
val html = VyatsuHttpClient.getText(VyatsuUrls.GROUPS_PAGE) val parsed = withContext(Dispatchers.IO) {
val groups = GroupsParser.parseGroups(html) VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
val ranges = GroupsParser.parseDateRanges(html) }
val entities = groups.map { group -> val now = now()
val range = ranges[group.id] groupDao.insertAll(
val autumn = range?.get(Season.AUTUMN)?.lastOrNull() parsed.groups.map { group ->
val spring = range?.get(Season.SPRING)?.lastOrNull()
GroupEntity( GroupEntity(
id = group.id, id = group.id,
name = group.name, name = group.name,
faculty = group.faculty, faculty = group.faculty,
autumnFirst = autumn?.first?.let(::epochDay), lastSyncedAt = now
autumnSecond = autumn?.second?.let(::epochDay),
springFirst = spring?.first?.let(::epochDay),
springSecond = spring?.second?.let(::epochDay),
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() private fun now(): Long = System.currentTimeMillis()
@@ -1,6 +1,7 @@
package agzam.vyatsu.data.repository package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DayEntity import agzam.vyatsu.data.local.DayEntity
import agzam.vyatsu.data.local.GroupPeriodEntity
import agzam.vyatsu.data.local.LessonEntity import agzam.vyatsu.data.local.LessonEntity
import agzam.vyatsu.data.local.ScheduleDao import agzam.vyatsu.data.local.ScheduleDao
import agzam.vyatsu.data.local.ScheduleEntity import agzam.vyatsu.data.local.ScheduleEntity
@@ -51,15 +52,38 @@ class ScheduleRepositoryImpl(
Log.w(TAG, "sync: group $groupId not found in DB") Log.w(TAG, "sync: group $groupId not found in DB")
return null return null
} }
val season = ScheduleDates.currentSeason() val today = ScheduleDates.moscowToday().toEpochDay()
val range = dateRangeFor(group, season) ?: run { val periods = groupDao.getCurrentAndFuturePeriods(groupId, today)
Log.w(TAG, "sync: no date range for season $season (group ${group.id})") if (periods.isEmpty()) {
Log.w(TAG, "sync: no current/future periods for group ${group.id}")
return null return null
} }
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
return try { for (period in periods) {
val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range) 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") Log.d(TAG, "sync: downloading $pdfUrl")
val bytes = VyatsuHttpClient.getBytes(pdfUrl) val bytes = VyatsuHttpClient.getBytes(pdfUrl)
Log.d(TAG, "sync: downloaded ${bytes.size} bytes") Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
@@ -67,29 +91,19 @@ class ScheduleRepositoryImpl(
val days = PdfScheduleParser.parse(bytes) val days = PdfScheduleParser.parse(bytes)
Log.d(TAG, "sync: parsed ${days.size} days") Log.d(TAG, "sync: parsed ${days.size} days")
val weekStart = ScheduleDates.asDate(range.first).toEpochDay() val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
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(
ScheduleEntity( ScheduleEntity(
groupId = groupId, groupId = group.id,
season = season, season = season,
weekStart = weekStart, weekStart = period.weekStart,
weekEnd = weekEnd, weekEnd = period.weekEnd,
lastSyncedAt = now() lastSyncedAt = now()
) )
) )
replaceDays(scheduleId, days) replaceDays(scheduleId, days)
Log.d(TAG, "sync: period $range synced")
scheduleDao.trim(groupId, MAX_PERIODS)
getSchedule(groupId)
} catch (e: Throwable) { } catch (e: Throwable) {
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e) Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
null
} }
} }
@@ -187,30 +201,13 @@ class ScheduleRepositoryImpl(
teacher = teacher 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 LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun now(): Long = System.currentTimeMillis() private fun now(): Long = System.currentTimeMillis()
companion object { companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L 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 const val TAG = "ScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy") private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
} }
@@ -54,17 +54,5 @@ fun BuildingsScreen() {
} }
HorizontalDivider(Modifier.padding(horizontal = 16.dp)) HorizontalDivider(Modifier.padding(horizontal = 16.dp))
} }
item {
Spacer(Modifier.height(24.dp))
Text(
"Версия ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
}
} }
} }
@@ -18,15 +18,22 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn 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.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.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.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -47,20 +54,27 @@ 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
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.draw.clip
import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.collectLatest
import java.time.DayOfWeek import java.time.DayOfWeek
import java.time.LocalDate import java.time.LocalDate
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
val hPadding = 16.dp val hPadding = 16.dp
@@ -75,6 +89,12 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
TopAppBar( TopAppBar(
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) }, title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
actions = { actions = {
if (state.isLoading && state.schedule != null) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
TextButton(onClick = { onSwitchGroup() }) { TextButton(onClick = { onSwitchGroup() }) {
Text("Сменить группу") Text("Сменить группу")
} }
@@ -86,16 +106,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
) )
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) { Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
when { when {
state.isSyncing && state.schedule == null -> { state.schedule == null && state.loadError -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
state.schedule == null -> {
Column( Column(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@@ -110,6 +121,15 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
} }
} }
state.schedule == null -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
else -> { else -> {
val schedule = state.schedule!! val schedule = state.schedule!!
val weekDays = state.weekStart val weekDays = state.weekStart
@@ -121,6 +141,32 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
if (day == null) { if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge) Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else { } 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 isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else "" val dayLabel = if (isTodayDay) "Сегодня " else ""
@@ -146,15 +192,25 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
days = weekDays, days = weekDays,
selectedDate = state.dayDate, selectedDate = state.dayDate,
todayDate = state.todayDate, todayDate = state.todayDate,
onSelect = viewModel::selectDay onSelect = viewModel::selectDay,
pagerState = pagerState
) )
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding)) 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) Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
}
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
@@ -168,6 +224,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
} }
} }
} }
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -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 @Composable
private fun DayChips( private fun DayChips(
days: List<Day>, days: List<Day>,
selectedDate: LocalDate?, selectedDate: LocalDate?,
todayDate: LocalDate?, todayDate: LocalDate?,
pagerState: PagerState,
onSelect: (LocalDate) -> Unit 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 -> items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date val isTodayChip = todayDate == day.date
@@ -37,6 +37,7 @@ class ScheduleViewModel(
fun selectGroup(groupId: String) { fun selectGroup(groupId: String) {
currentGroupId = groupId currentGroupId = groupId
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
viewModelScope.launch { viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId) preferencesRepository.setSelectedGroup(groupId)
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
@@ -61,7 +62,7 @@ class ScheduleViewModel(
} }
launch { launch {
syncIfMissingToday(groupId) syncIfMissing(groupId)
} }
} }
} }
@@ -96,7 +97,9 @@ class ScheduleViewModel(
schedule = schedule, schedule = schedule,
weekStart = weekStart, weekStart = weekStart,
dayDate = dayDate, dayDate = dayDate,
todayDate = todayDate todayDate = todayDate,
isLoading = false,
loadError = false
) )
} }
@@ -128,30 +131,39 @@ class ScheduleViewModel(
} }
} }
private suspend fun syncIfMissingToday(groupId: String) { private suspend fun syncIfMissing(groupId: String) {
val schedule = scheduleRepository.getSchedule(groupId) if (scheduleRepository.getSchedule(groupId) != null) {
val today = ScheduleDates.moscowToday() _uiState.value = _uiState.value.copy(isLoading = false)
val hasTodayPeriod = schedule?.periodOf(today) != null return
if (hasTodayPeriod) return }
_uiState.value = _uiState.value.copy(isSyncing = true) loadSchedule(groupId)
scheduleRepository.sync(groupId) }
_uiState.value = _uiState.value.copy(isSyncing = false)
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() { fun switchGroup() {
viewModelScope.launch { viewModelScope.launch {
currentGroupId = null currentGroupId = null
preferencesRepository.clearSelectedGroup() preferencesRepository.clearSelectedGroup()
_uiState.value = _uiState.value.copy(noGroup = true) _uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
} }
} }
fun refresh() { fun refresh() {
val groupId = currentGroupId ?: return val groupId = currentGroupId ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true) _uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
scheduleRepository.sync(groupId) // Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
_uiState.value = _uiState.value.copy(isRefreshing = false) runCatching { groupRepository.forceRefresh() }
loadSchedule(groupId)
} }
} }
@@ -175,8 +187,8 @@ class ScheduleViewModel(
data class ScheduleUiState( data class ScheduleUiState(
val noGroup: Boolean = false, val noGroup: Boolean = false,
val isRefreshing: Boolean = false, val isLoading: Boolean = false,
val isSyncing: Boolean = false, val loadError: Boolean = false,
val schedule: Schedule? = null, val schedule: Schedule? = null,
val groupName: String? = null, val groupName: String? = null,
val weekStart: LocalDate? = null, val weekStart: LocalDate? = null,
@@ -118,7 +118,7 @@ class AppUpdater(
} }
val remoteVersion = release.tagName?.trimStart('v', 'V') val remoteVersion = release.tagName?.trimStart('v', 'V')
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME)) { if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME.trimStart('v', 'V'))) {
_state.value = UpdateState.UpToDate _state.value = UpdateState.UpToDate
return null return null
} }