Compare commits

...
14 Commits
15 changed files with 681 additions and 94 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-секрет и декодируется в файл.
+3 -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"
+19
View File
@@ -19,3 +19,22 @@
# 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.** { *; }
# Moshi удаляется proguard
-keepnames @com.squareup.moshi.JsonClass class *
-keep class **JsonAdapter {
public <init>(...);
<fields>;
}
-keepclassmembers @com.squareup.moshi.JsonClass class * {
synthetic <init>(...);
}
@@ -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')"
]
}
}
@@ -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
)
@@ -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,12 +2,12 @@ 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.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -45,25 +45,32 @@ class GroupRepositoryImpl(
val parsed = withContext(Dispatchers.IO) { val parsed = withContext(Dispatchers.IO) {
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream) VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
} }
val entities = parsed.groups.map { group -> val now = now()
val range = parsed.dateRanges[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))
}
} }
} }
@@ -25,9 +25,15 @@ 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
@@ -48,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
@@ -128,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 ""
@@ -153,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)
) { ) {
@@ -175,6 +224,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
} }
} }
} }
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -242,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
@@ -160,6 +160,9 @@ class ScheduleViewModel(
fun refresh() { fun refresh() {
val groupId = currentGroupId ?: return val groupId = currentGroupId ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
runCatching { groupRepository.forceRefresh() }
loadSchedule(groupId) loadSchedule(groupId)
} }
} }
@@ -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
} }