Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1077be1897 | ||
|
|
96db4fa3a6 | ||
|
|
830b4664fb | ||
|
|
dd4fb90dc4 | ||
|
|
a55fb0461c | ||
|
|
e789729c76 | ||
|
|
7e23895f0d | ||
|
|
708ad11dca | ||
|
|
b1418f79da | ||
|
|
d17c70e871 | ||
|
|
6cd211f744 | ||
|
|
019cf5da21 | ||
|
|
a24748ec04 | ||
|
|
caf3debb3f |
@@ -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-секрет и декодируется в файл.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Vendored
+19
@@ -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)
|
||||||
) {
|
) {
|
||||||
@@ -176,6 +225,7 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user