Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb1e3edca | ||
|
|
84da62c45d | ||
|
|
57e5bd9af8 | ||
|
|
c7298c0c84 | ||
|
|
ae0fea1ea6 | ||
|
|
af1f374b95 | ||
|
|
4f191e9f2f | ||
|
|
e16454a196 | ||
|
|
ef81659521 | ||
|
|
6189540acf | ||
|
|
1d55e84e96 | ||
|
|
39e4f5a04f | ||
|
|
e1b2f05548 | ||
|
|
f5696fbbd6 | ||
|
|
b9b3a18f0c | ||
|
|
2dd8b8c469 | ||
|
|
6c06892d9a | ||
|
|
9dce30931b | ||
|
|
32c2b60afa | ||
|
|
458e4c4e69 | ||
|
|
1a4bff73d0 | ||
|
|
a5ea516713 | ||
|
|
17d437aace | ||
|
|
1077be1897 | ||
|
|
96db4fa3a6 | ||
|
|
830b4664fb | ||
|
|
dd4fb90dc4 | ||
|
|
a55fb0461c | ||
|
|
e789729c76 | ||
|
|
7e23895f0d | ||
|
|
708ad11dca | ||
|
|
b1418f79da | ||
|
|
d17c70e871 | ||
|
|
6cd211f744 | ||
|
|
019cf5da21 | ||
|
|
a24748ec04 | ||
|
|
caf3debb3f | ||
|
|
1c5140d751 | ||
|
|
65bb6cc7db | ||
|
|
99e05e8232 | ||
|
|
c4ecc43597 | ||
|
|
9a96bd3dac | ||
|
|
c4345f9dbe | ||
|
|
4797811097 | ||
|
|
2dfef426a7 | ||
|
|
97c19647cc | ||
|
|
dea27bc8f1 | ||
|
|
bafebfd253 | ||
|
|
f19d65abb5 | ||
|
|
889c57bbba | ||
|
|
ec6a7174e8 | ||
|
|
014896aaea | ||
|
|
9f6cf3ef30 | ||
|
|
d1923d3296 | ||
|
|
686976347e | ||
|
|
699bcd81f5 | ||
|
|
050f835a33 | ||
|
|
5fee57d93a | ||
|
|
36711d1bf3 | ||
|
|
c2a9cfb7e7 | ||
|
|
467c1a735f | ||
|
|
28965e6aed | ||
|
|
181f8b2f2a | ||
|
|
84ae97b906 | ||
|
|
2069ec811a |
@@ -18,42 +18,19 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
releases: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 17
|
||||
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
||||
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
||||
- name: Verify Environment
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y openjdk-17-jdk-headless unzip wget
|
||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Android SDK
|
||||
# Устанавливаем command-line tools и нужные компоненты SDK,
|
||||
# затем прописываем ANDROID_HOME (читается Gradle/AGP).
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
echo "ANDROID_HOME=/opt/android-sdk" >> "$GITHUB_ENV"
|
||||
mkdir -p /opt/android-sdk/cmdline-tools
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O /tmp/clt.zip
|
||||
unzip -q /tmp/clt.zip -d /tmp/clt
|
||||
mv /tmp/clt/cmdline-tools /opt/android-sdk/cmdline-tools/latest
|
||||
yes | /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null 2>&1 || true
|
||||
/opt/android-sdk/cmdline-tools/latest/bin/sdkmanager \
|
||||
"platform-tools" \
|
||||
"platforms;android-36.1" \
|
||||
"build-tools;36.1.0"
|
||||
java -version
|
||||
echo "SDK path: $ANDROID_HOME"
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: |
|
||||
chmod +x gradlew
|
||||
java -version
|
||||
|
||||
# Версия по дате (ГГГГ.ММ.ДД), при совпадении тега добавляем уникальный суффикс.
|
||||
- name: Generate version from date
|
||||
@@ -66,9 +43,9 @@ jobs:
|
||||
SUFFIX=$((SUFFIX + 1))
|
||||
TAG="v${BASE}.${SUFFIX}"
|
||||
done
|
||||
echo "version_name=$BASE" >> "$GITHUB_OUTPUT"
|
||||
echo "version_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Generated version: $BASE (tag: $TAG)"
|
||||
echo "Generated version: $BASE"
|
||||
|
||||
- name: Restore keystore from secret
|
||||
# Ключ подписи передаётся как base64-секрет и декодируется в файл.
|
||||
@@ -85,6 +62,9 @@ jobs:
|
||||
- name: Build release APK
|
||||
run: ./gradlew assembleRelease
|
||||
env:
|
||||
# Версия приложения — дата, сгенерированная на предыдущем шаге.
|
||||
# Читается в app/build.gradle.kts как versionName (для BuildConfig).
|
||||
APP_VERSION: ${{ steps.version.outputs.version_name }}
|
||||
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
|
||||
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
@@ -112,9 +92,3 @@ jobs:
|
||||
files: ${{ steps.apk.outputs.apk_path }}
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
server_url: ${{ gitea.server_url }}
|
||||
|
||||
- name: Upload APK as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vyatsu-schedule-${{ steps.version.outputs.version_name }}
|
||||
path: ${{ steps.apk.outputs.apk_path }}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
# vyatsu-schedule
|
||||
# ВятГУ расписание _(неофициальный Android клиент)_
|
||||
|
||||
|
||||
|
||||
Расписание загружается и парсится прямиком с сайта ВятГУ, и сохраняется в локальную БД телефона без промежуточных серверов
|
||||
|
||||
- Расписание групп - парсится из PDF-ок ([тута](https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/raspisanie-zanyatiy-dlya-studentov.html))
|
||||
- Расписание преподавателей - загружаются все расписания преподавателей [отсюда](https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/teacher.html) на выбранный переиод (ибо раскиданы по кафедрам)
|
||||
@@ -17,7 +17,9 @@ android {
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
|
||||
// Локально и при отсутствии переменной — fallback "1.0".
|
||||
versionName = System.getenv("APP_VERSION") ?: "debug"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -38,7 +40,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
@@ -51,6 +54,7 @@ android {
|
||||
}
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
unitTests.all { it.maxHeapSize = "16m" }
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
@@ -113,4 +117,6 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
|
||||
}
|
||||
|
||||
Vendored
+19
@@ -19,3 +19,22 @@
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 4,
|
||||
"identityHash": "38abab3f8c5b39625b6a4de9d58968ca",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "departments",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`departmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`departmentId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "departmentId",
|
||||
"columnName": "departmentId",
|
||||
"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": [
|
||||
"departmentId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "department_periods",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "departmentId",
|
||||
"columnName": "departmentId",
|
||||
"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_department_periods_departmentId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"departmentId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_department_periods_departmentId` ON `${TABLE_NAME}` (`departmentId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "departments",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"departmentId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"departmentId"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "teacher_schedules",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "departmentId",
|
||||
"columnName": "departmentId",
|
||||
"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_teacher_schedules_departmentId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"departmentId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId` ON `${TABLE_NAME}` (`departmentId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_teacher_schedules_departmentId_season_weekStart",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"departmentId",
|
||||
"season",
|
||||
"weekStart"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId_season_weekStart` ON `${TABLE_NAME}` (`departmentId`, `season`, `weekStart`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "departments",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"departmentId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"departmentId"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "teacher_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 `teacher_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_teacher_days_scheduleId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"scheduleId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_teacher_days_date",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"date"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_days_date` ON `${TABLE_NAME}` (`date`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "teacher_schedules",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"scheduleId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "teacher_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 NOT NULL, FOREIGN KEY(`dayId`) REFERENCES `teacher_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",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_teacher_lessons_dayId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"dayId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_teacher_lessons_teacher",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"teacher"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_teacher` ON `${TABLE_NAME}` (`teacher`)"
|
||||
},
|
||||
{
|
||||
"name": "index_teacher_lessons_room",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"room"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_room` ON `${TABLE_NAME}` (`room`)"
|
||||
},
|
||||
{
|
||||
"name": "index_teacher_lessons_subject",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subject"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "teacher_days",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"dayId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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, '38abab3f8c5b39625b6a4de9d58968ca')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,16 @@
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".VyatsuApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:icon="@drawable/icon"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.VyatsuSchedule">
|
||||
@@ -25,6 +27,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,10 +1,15 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.di.AppContainer
|
||||
import agzam.vyatsu.ui.groups.GroupPickerScreen
|
||||
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
||||
import agzam.vyatsu.ui.home.HomeScreen
|
||||
import agzam.vyatsu.ui.picker.PickerScreen
|
||||
import agzam.vyatsu.ui.picker.PickerViewModel
|
||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||
import agzam.vyatsu.ui.teacher.TeacherScheduleViewModel
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -19,20 +24,51 @@ fun AppRoot() {
|
||||
val container = app.container
|
||||
|
||||
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||
val teacherScheduleViewModel: TeacherScheduleViewModel = viewModel(factory = teacherScheduleFactory(container))
|
||||
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
||||
|
||||
val state by scheduleViewModel.uiState.collectAsState()
|
||||
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
|
||||
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
|
||||
|
||||
if (state.noGroup) {
|
||||
GroupPickerScreen(
|
||||
viewModel = groupPickerViewModel,
|
||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||
if (showFirstRunDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { updateViewModel.onFirstRunDecision(false) },
|
||||
title = { Text("Автоматические обновления") },
|
||||
text = {
|
||||
Text(
|
||||
"Разрешить автоматически проверять и устанавливать " +
|
||||
"новые версии приложения?"
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { updateViewModel.onFirstRunDecision(true) }) {
|
||||
Text("Разрешить")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { updateViewModel.onFirstRunDecision(false) }) {
|
||||
Text("Не надо")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.noGroup && teacherState.noTeacher) {
|
||||
val pickerViewModel: PickerViewModel = viewModel(factory = pickerFactory(container))
|
||||
PickerScreen(
|
||||
viewModel = pickerViewModel,
|
||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) },
|
||||
onTeacherPicked = { teacher -> teacherScheduleViewModel.selectTeacher(teacher.name) }
|
||||
)
|
||||
return
|
||||
}
|
||||
HomeScreen(
|
||||
scheduleViewModel = scheduleViewModel,
|
||||
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
||||
teacherScheduleViewModel = teacherScheduleViewModel,
|
||||
updateViewModel = updateViewModel,
|
||||
onSwitchGroup = { scheduleViewModel.switchGroup() },
|
||||
onSwitchTeacher = { teacherScheduleViewModel.switchTeacher() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,5 +77,14 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
|
||||
}
|
||||
|
||||
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository) }
|
||||
private fun groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
|
||||
private fun teacherScheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { TeacherScheduleViewModel(c.teacherScheduleRepository, c.preferencesRepository) }
|
||||
private fun pickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory {
|
||||
PickerViewModel(
|
||||
c.groupRepository,
|
||||
c.teacherRepository,
|
||||
c.teacherScheduleRepository,
|
||||
c.preferencesRepository
|
||||
)
|
||||
}
|
||||
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
|
||||
|
||||
@@ -10,6 +10,8 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val container = (application as VyatsuApplication).container
|
||||
container.appUpdater.autoCheckAndInstall()
|
||||
setContent {
|
||||
VyatsuScheduleTheme {
|
||||
AppRoot()
|
||||
|
||||
@@ -3,16 +3,24 @@ package agzam.vyatsu.data.local
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
GroupEntity::class,
|
||||
GroupPeriodEntity::class,
|
||||
ScheduleEntity::class,
|
||||
DayEntity::class,
|
||||
LessonEntity::class,
|
||||
PreferenceEntity::class
|
||||
PreferenceEntity::class,
|
||||
DepartmentEntity::class,
|
||||
DepartmentPeriodEntity::class,
|
||||
TeacherScheduleEntity::class,
|
||||
TeacherDayEntity::class,
|
||||
TeacherLessonEntity::class
|
||||
],
|
||||
version = 2,
|
||||
version = 4,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -20,4 +28,91 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun groupDao(): GroupDao
|
||||
abstract fun scheduleDao(): ScheduleDao
|
||||
abstract fun preferencesDao(): PreferencesDao
|
||||
abstract fun teacherDao(): TeacherDao
|
||||
abstract fun teacherScheduleDao(): TeacherScheduleDao
|
||||
|
||||
companion object {
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `departments` (" +
|
||||
"`departmentId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
||||
"`faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, " +
|
||||
"PRIMARY KEY(`departmentId`))"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `department_periods` (" +
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, " +
|
||||
"`weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, " +
|
||||
"FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_department_periods_departmentId` " +
|
||||
"ON `department_periods` (`departmentId`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `teacher_schedules` (" +
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, " +
|
||||
"`weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, " +
|
||||
"`lastSyncedAt` INTEGER NOT NULL, " +
|
||||
"FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId` " +
|
||||
"ON `teacher_schedules` (`departmentId`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId_season_weekStart` " +
|
||||
"ON `teacher_schedules` (`departmentId`, `season`, `weekStart`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `teacher_days` (" +
|
||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
||||
"`scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, " +
|
||||
"FOREIGN KEY(`scheduleId`) REFERENCES `teacher_schedules`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_teacher_days_scheduleId` " +
|
||||
"ON `teacher_days` (`scheduleId`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_teacher_days_date` " +
|
||||
"ON `teacher_days` (`date`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE TABLE IF NOT EXISTS `teacher_lessons` (" +
|
||||
"`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 NOT NULL, " +
|
||||
"FOREIGN KEY(`dayId`) REFERENCES `teacher_days`(`id`) " +
|
||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_dayId` " +
|
||||
"ON `teacher_lessons` (`dayId`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_teacher` " +
|
||||
"ON `teacher_lessons` (`teacher`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_room` " +
|
||||
"ON `teacher_lessons` (`room`)"
|
||||
)
|
||||
db.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_subject` " +
|
||||
"ON `teacher_lessons` (`subject`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "departments")
|
||||
data class DepartmentEntity(
|
||||
@PrimaryKey val departmentId: String,
|
||||
val name: String,
|
||||
val faculty: String,
|
||||
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 = "department_periods",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DepartmentEntity::class,
|
||||
parentColumns = ["departmentId"],
|
||||
childColumns = ["departmentId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("departmentId")]
|
||||
)
|
||||
data class DepartmentPeriodEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val departmentId: String,
|
||||
val season: Season,
|
||||
val weekStart: Long,
|
||||
val weekEnd: Long
|
||||
)
|
||||
@@ -25,4 +25,13 @@ interface GroupDao {
|
||||
|
||||
@Query("SELECT MAX(lastSyncedAt) FROM groups")
|
||||
suspend fun maxSyncedAt(): Long?
|
||||
|
||||
@Query("SELECT * FROM group_periods WHERE groupId = :groupId AND weekEnd >= :fromEpochDay ORDER BY weekStart")
|
||||
suspend fun getCurrentAndFuturePeriods(groupId: String, fromEpochDay: Long): List<GroupPeriodEntity>
|
||||
|
||||
@Query("DELETE FROM group_periods")
|
||||
suspend fun deleteAllPeriods()
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertPeriods(periods: List<GroupPeriodEntity>)
|
||||
}
|
||||
|
||||
@@ -8,9 +8,5 @@ data class GroupEntity(
|
||||
@PrimaryKey val id: String,
|
||||
val name: String,
|
||||
val faculty: String,
|
||||
val autumnFirst: Long?,
|
||||
val autumnSecond: Long?,
|
||||
val springFirst: Long?,
|
||||
val springSecond: Long?,
|
||||
val lastSyncedAt: Long
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
// Все опубликованные на сайте 2-недельные периоды группы, включая ещё не начавшиеся.
|
||||
@Entity(
|
||||
tableName = "group_periods",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = GroupEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["groupId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [Index("groupId")]
|
||||
)
|
||||
data class GroupPeriodEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val groupId: String,
|
||||
val season: Season,
|
||||
val weekStart: Long,
|
||||
val weekEnd: Long
|
||||
)
|
||||
@@ -27,4 +27,46 @@ interface PreferencesDao {
|
||||
|
||||
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
||||
suspend fun setSelectedDayDate(date: String)
|
||||
|
||||
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherId'")
|
||||
suspend fun selectedTeacherId(): String?
|
||||
|
||||
@Query(
|
||||
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherId', :teacherId)"
|
||||
)
|
||||
suspend fun setSelectedTeacherId(teacherId: String)
|
||||
|
||||
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherWeekStart'")
|
||||
suspend fun selectedTeacherWeekStart(): String?
|
||||
|
||||
@Query(
|
||||
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherWeekStart', :weekStart)"
|
||||
)
|
||||
suspend fun setSelectedTeacherWeekStart(weekStart: String)
|
||||
|
||||
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherDayDate'")
|
||||
suspend fun selectedTeacherDayDate(): String?
|
||||
|
||||
@Query(
|
||||
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherDayDate', :date)"
|
||||
)
|
||||
suspend fun setSelectedTeacherDayDate(date: String)
|
||||
|
||||
@Query(
|
||||
"DELETE FROM preferences WHERE key IN " +
|
||||
"('selectedTeacherId', 'selectedTeacherWeekStart', 'selectedTeacherDayDate')"
|
||||
)
|
||||
suspend fun clearSelectedTeacher()
|
||||
|
||||
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateEnabled'")
|
||||
suspend fun autoUpdateEnabled(): String?
|
||||
|
||||
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateEnabled', :enabled)")
|
||||
suspend fun setAutoUpdateEnabled(enabled: String)
|
||||
|
||||
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateAsked'")
|
||||
suspend fun autoUpdateAsked(): String?
|
||||
|
||||
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateAsked', :asked)")
|
||||
suspend fun setAutoUpdateAsked(asked: String)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TeacherDao {
|
||||
@Query("SELECT * FROM departments ORDER BY name")
|
||||
fun observeDepartments(): Flow<List<DepartmentEntity>>
|
||||
|
||||
@Query("SELECT * FROM departments WHERE departmentId = :id")
|
||||
suspend fun getDepartment(id: String): DepartmentEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(departments: List<DepartmentEntity>)
|
||||
|
||||
@Query("SELECT MAX(lastSyncedAt) FROM departments")
|
||||
suspend fun maxSyncedAt(): Long?
|
||||
|
||||
@Query("SELECT DISTINCT faculty FROM departments ORDER BY faculty")
|
||||
suspend fun getFaculties(): List<String>
|
||||
|
||||
@Query("DELETE FROM department_periods")
|
||||
suspend fun deleteAllPeriods()
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertPeriods(periods: List<DepartmentPeriodEntity>)
|
||||
|
||||
@Query(
|
||||
"SELECT * FROM department_periods WHERE weekStart <= :epochDay AND weekEnd >= :epochDay ORDER BY departmentId"
|
||||
)
|
||||
suspend fun getPeriodsForDate(epochDay: Long): List<DepartmentPeriodEntity>
|
||||
|
||||
@Query("SELECT * FROM department_periods WHERE departmentId = :departmentId ORDER BY weekStart")
|
||||
suspend fun getPeriodsForDepartment(departmentId: String): List<DepartmentPeriodEntity>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "teacher_days",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = TeacherScheduleEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["scheduleId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("scheduleId"),
|
||||
Index("date")
|
||||
]
|
||||
)
|
||||
data class TeacherDayEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val scheduleId: Long,
|
||||
val date: Long
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "teacher_lessons",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = TeacherDayEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["dayId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("dayId"),
|
||||
Index("teacher"),
|
||||
Index("room"),
|
||||
Index("subject")
|
||||
]
|
||||
)
|
||||
data class TeacherLessonEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val dayId: Long,
|
||||
val date: Long,
|
||||
val slotIndex: Int,
|
||||
val start: Int,
|
||||
val end: Int,
|
||||
val lessonIndex: Int,
|
||||
val raw: String,
|
||||
val room: String?,
|
||||
val groupName: String?,
|
||||
val subgroup: String?,
|
||||
val subject: String?,
|
||||
val type: LessonType,
|
||||
val teacher: String
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Embedded
|
||||
|
||||
/** Занятие преподавателя + кафедра-источник. Проекция LEFT JOIN, схема Room не меняется. */
|
||||
data class TeacherLessonWithDepartment(
|
||||
@Embedded val lesson: TeacherLessonEntity,
|
||||
val departmentId: String,
|
||||
val departmentName: String?
|
||||
)
|
||||
@@ -0,0 +1,87 @@
|
||||
package agzam.vyatsu.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface TeacherScheduleDao {
|
||||
|
||||
@Query("SELECT * FROM teacher_schedules ORDER BY weekStart DESC")
|
||||
fun observeSchedules(): Flow<List<TeacherScheduleEntity>>
|
||||
|
||||
@Query("SELECT * FROM teacher_schedules ORDER BY weekStart DESC")
|
||||
suspend fun getSchedules(): List<TeacherScheduleEntity>
|
||||
|
||||
@Query("DELETE FROM teacher_days WHERE scheduleId = :scheduleId")
|
||||
suspend fun deleteDaysForSchedule(scheduleId: Long)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
suspend fun insertSchedule(schedule: TeacherScheduleEntity): Long
|
||||
|
||||
@Query(
|
||||
"DELETE FROM teacher_schedules WHERE departmentId = :departmentId AND season = :season AND weekStart = :weekStart"
|
||||
)
|
||||
suspend fun deleteSchedule(departmentId: String, season: String, weekStart: Long)
|
||||
|
||||
@Query("DELETE FROM teacher_schedules WHERE season = :season AND weekStart = :weekStart")
|
||||
suspend fun deletePeriod(season: String, weekStart: Long)
|
||||
|
||||
@Query("DELETE FROM teacher_schedules")
|
||||
suspend fun deleteAll()
|
||||
|
||||
@Query("SELECT lastSyncedAt FROM teacher_schedules WHERE departmentId = :departmentId AND season = :season AND weekStart = :weekStart")
|
||||
suspend fun lastSyncedAt(departmentId: String, season: String, weekStart: Long): Long?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertDays(days: List<TeacherDayEntity>): List<Long>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertLessons(lessons: List<TeacherLessonEntity>)
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceSchedule(schedule: TeacherScheduleEntity, days: List<TeacherDayEntity>, lessons: List<TeacherLessonEntity>) {
|
||||
deleteSchedule(schedule.departmentId, schedule.season.name, schedule.weekStart)
|
||||
val scheduleId = insertSchedule(schedule)
|
||||
val daysWithScheduleId = days.map { it.copy(scheduleId = scheduleId) }
|
||||
deleteDaysForSchedule(scheduleId)
|
||||
val dayIds = insertDays(daysWithScheduleId)
|
||||
val dayIdByDate = daysWithScheduleId.zip(dayIds).associate { (day, id) -> day.date to id }
|
||||
insertLessons(lessons.map { lesson -> lesson.copy(dayId = dayIdByDate.getValue(lesson.date)) })
|
||||
}
|
||||
|
||||
@Query(
|
||||
"SELECT teacher_days.* FROM teacher_days INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
|
||||
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
|
||||
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher_days.date"
|
||||
)
|
||||
fun observeDaysForPeriod(season: String, weekStart: Long): Flow<List<TeacherDayEntity>>
|
||||
|
||||
@Query(
|
||||
"SELECT teacher_lessons.*, teacher_schedules.departmentId AS departmentId, departments.name AS departmentName " +
|
||||
"FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
|
||||
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
|
||||
"LEFT JOIN departments ON departments.departmentId = teacher_schedules.departmentId " +
|
||||
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
|
||||
"AND teacher_schedules.weekEnd >= :weekStart " +
|
||||
"ORDER BY teacher_lessons.date, teacher_lessons.slotIndex, teacher_lessons.lessonIndex"
|
||||
)
|
||||
fun observeLessonsForPeriod(season: String, weekStart: Long): Flow<List<TeacherLessonWithDepartment>>
|
||||
|
||||
@Query(
|
||||
"SELECT DISTINCT teacher FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
|
||||
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
|
||||
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
|
||||
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher"
|
||||
)
|
||||
fun observeDistinctTeachers(season: String, weekStart: Long): Flow<List<String>>
|
||||
|
||||
@Query(
|
||||
"SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season " +
|
||||
"AND weekStart <= :weekStart AND weekEnd >= :weekStart)"
|
||||
)
|
||||
suspend fun hasSchedulesForPeriod(season: String, weekStart: Long): Boolean
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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
|
||||
|
||||
@Entity(
|
||||
tableName = "teacher_schedules",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = DepartmentEntity::class,
|
||||
parentColumns = ["departmentId"],
|
||||
childColumns = ["departmentId"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("departmentId"),
|
||||
Index(value = ["departmentId", "season", "weekStart"], unique = true)
|
||||
]
|
||||
)
|
||||
data class TeacherScheduleEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val departmentId: String,
|
||||
val season: Season,
|
||||
val weekStart: Long,
|
||||
val weekEnd: Long,
|
||||
val lastSyncedAt: Long
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
package agzam.vyatsu.data.remote
|
||||
|
||||
/**
|
||||
* Справочник сокращённых названий факультетов ВятГУ.
|
||||
* Вынесен из GroupsParser, так как TeachersParser использует те же сокращения.
|
||||
*/
|
||||
object FacultyNames {
|
||||
|
||||
private val parenthesized = Regex("""\(.*?\)\s?""")
|
||||
|
||||
private val shorthands = mapOf(
|
||||
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
|
||||
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
|
||||
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
|
||||
"Факультет лингвистики (ОРУ)" to "ФЛ",
|
||||
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
|
||||
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
|
||||
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
|
||||
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
|
||||
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
|
||||
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
|
||||
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
|
||||
"Электротехнический факультет (ОРУ)" to "ЭТФ",
|
||||
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
|
||||
)
|
||||
|
||||
fun shortname(name: String): String {
|
||||
val stripped = removeParenthesized(name)
|
||||
val sh = shorthands[name]
|
||||
return if (sh != null) "$sh ($stripped)" else stripped
|
||||
}
|
||||
|
||||
fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package agzam.vyatsu.data.remote
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import android.util.Log
|
||||
import org.jsoup.Jsoup
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
||||
@@ -14,67 +13,145 @@ object GroupsParser {
|
||||
|
||||
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
||||
|
||||
private val shorthands = mapOf(
|
||||
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
|
||||
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
|
||||
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
|
||||
"Факультет лингвистики (ОРУ)" to "ФЛ",
|
||||
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
|
||||
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
|
||||
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
|
||||
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
|
||||
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
|
||||
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
|
||||
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
|
||||
"Электротехнический факультет (ОРУ)" to "ЭТФ",
|
||||
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
|
||||
)
|
||||
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
||||
|
||||
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 — жёсткий потолок буфера
|
||||
|
||||
/**
|
||||
* Извлекает список групп с HTML страницы
|
||||
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
||||
* разбирает его на лету, не материализуя весь HTML в память.
|
||||
*
|
||||
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
|
||||
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
|
||||
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
|
||||
*/
|
||||
fun parseGroups(html: String): List<Group> {
|
||||
val doc = Jsoup.parse(html)
|
||||
val container = doc.selectFirst("div.column-center_rasp") ?: return emptyList()
|
||||
val tables = container.select("table[style~=border: none !important]")
|
||||
fun parseStream(input: InputStream): ParsedGroups {
|
||||
val reader = input.bufferedReader(Charsets.UTF_8)
|
||||
val buf = CharArray(CHUNK_SIZE)
|
||||
val pending = StringBuilder(CHUNK_SIZE * 2)
|
||||
val groups = ArrayList<Group>()
|
||||
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||
var faculty = ""
|
||||
|
||||
return tables.flatMap { table ->
|
||||
table.select("> tbody > tr, > tr").flatMap { row ->
|
||||
Log.v("ROW", row.html());
|
||||
val faculty = shortname(row.selectFirst("div.fak_name")?.text().orEmpty()) //
|
||||
|
||||
row.select("div.grpPeriod").map { grp ->
|
||||
Group(
|
||||
id = grp.attr("data-grp_period_id").dropLast(1),
|
||||
name = removeParenthesized(grp.text()),
|
||||
faculty = faculty
|
||||
)
|
||||
}
|
||||
fun addRanges(text: String) {
|
||||
for (m in groupPattern.findAll(text)) {
|
||||
val groupId = m.groupValues[1]
|
||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||
ranges.getOrPut(groupId) {
|
||||
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
|
||||
}.getOrPut(season) { ArrayList() }.add(range)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseDateRanges(html: String): Map<String, Map<Season, List<DateRange>>> {
|
||||
val result = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||
groupPattern.findAll(html).forEach { m ->
|
||||
val groupId = m.groupValues[1]
|
||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||
result
|
||||
.getOrPut(groupId) { hashMapOf(Season.AUTUMN to mutableListOf(), Season.SPRING to mutableListOf()) }
|
||||
.getOrPut(season) { mutableListOf() }
|
||||
.add(range)
|
||||
while (true) {
|
||||
val n = reader.read(buf)
|
||||
if (n < 0) break
|
||||
pending.append(buf, 0, n)
|
||||
|
||||
var pos = 0
|
||||
while (pos < pending.length) {
|
||||
val divAt = pending.indexOf("<div", pos)
|
||||
val aAt = pending.indexOf("<a", pos)
|
||||
val tagStart = when {
|
||||
divAt < 0 -> aAt
|
||||
aAt < 0 -> divAt
|
||||
else -> minOf(divAt, aAt)
|
||||
}
|
||||
if (tagStart < 0) {
|
||||
pos = keepPartialTagTail(pending, pos)
|
||||
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 = FacultyNames.shortname(name)
|
||||
} else {
|
||||
val m = grpIdPattern.find(tagText)
|
||||
if (m != null) {
|
||||
groups += Group(
|
||||
id = m.groupValues[1].dropLast(1),
|
||||
name = FacultyNames.removeParenthesized(content),
|
||||
faculty = faculty
|
||||
)
|
||||
}
|
||||
}
|
||||
pos = closeDiv + 6
|
||||
} else {
|
||||
addRanges(tagText)
|
||||
pos = tagEnd + 1
|
||||
}
|
||||
|
||||
if (pos > MAX_SLICE) {
|
||||
pending.delete(0, pos)
|
||||
pos = 0
|
||||
}
|
||||
}
|
||||
if (pos > 0 && pos <= pending.length) {
|
||||
pending.delete(0, pos)
|
||||
}
|
||||
if (pending.length > MAX_PENDING) {
|
||||
// на странице нет настолько длинных конструкций; страховка от аномалий
|
||||
pending.delete(0, pending.length - MAX_SLICE)
|
||||
}
|
||||
}
|
||||
return result
|
||||
return ParsedGroups(groups, ranges)
|
||||
}
|
||||
|
||||
private fun shortname(name: String): String {
|
||||
val stripped = removeParenthesized(name)
|
||||
val sh = shorthands[name]
|
||||
return if (sh != null) "$sh ($stripped)" else stripped
|
||||
/** Ищет конец 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 removeParenthesized(name: String): String = name.replace(Regex("""\(.*?\)\s?"""), "").trim()
|
||||
/**
|
||||
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
|
||||
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
|
||||
*/
|
||||
private fun keepPartialTagTail(pending: CharSequence, from: Int): Int {
|
||||
var i = from
|
||||
while (i < pending.length) {
|
||||
val c = pending[i]
|
||||
if (c == '<') {
|
||||
val tail = pending.substring(i, pending.length)
|
||||
if (tail == "<" || tail == "<d" || tail == "<di" || tail == "<a") return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return pending.length
|
||||
}
|
||||
}
|
||||
|
||||
data class ParsedGroups(
|
||||
val groups: List<Group>,
|
||||
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
package agzam.vyatsu.data.remote
|
||||
|
||||
import agzam.vyatsu.data.remote.pdf.TextPatterns
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.Lesson
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import android.util.Log
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
object TeacherScheduleParser {
|
||||
|
||||
private const val TAG = "TeacherParserDebug"
|
||||
private val DAY_REGEX = Regex("""^(понедельник|вторник|среда|четверг|пятница|суббота|воскресенье)\s+(\d{2}\.\d{2}\.\d{2})$""")
|
||||
private val INTERVAL_REGEX = Regex("""^(\d{2}:\d{2})-(\d{2}:\d{2})$""")
|
||||
private val GROUP_TOKEN = Regex("""(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}])""")
|
||||
private val SPLIT_BY_GROUP = Regex("""(?=(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}]))""")
|
||||
private val DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yy")
|
||||
|
||||
data class ParsedTeacherSchedule(val teachers: List<String>, val days: List<Day>)
|
||||
|
||||
/**
|
||||
* Столбец расписания преподавателя: имя и абсолютный индекс ячейки в шапке.
|
||||
* Индекс нужен для точного выравнивания с рядами данных — в шапке могут
|
||||
* присутствовать пустые колонки-разделители, которые не должны сдвигать
|
||||
* соответствие «преподаватель → столбец».
|
||||
*/
|
||||
private data class Column(val teacher: String, val headerIndex: Int)
|
||||
|
||||
fun parse(html: String): ParsedTeacherSchedule {
|
||||
val doc = Jsoup.parse(html)
|
||||
var headerFound = false
|
||||
var columns: List<Column> = emptyList()
|
||||
var teachers: List<String> = emptyList()
|
||||
val days = mutableListOf<Day>()
|
||||
var currentDate: LocalDate? = null
|
||||
var currentIntervals: MutableList<TimeSlot>? = null
|
||||
|
||||
fun flushDay() {
|
||||
val date = currentDate ?: return
|
||||
val intervals = currentIntervals ?: return
|
||||
if (intervals.isNotEmpty()) days.add(Day(date, intervals.toList()))
|
||||
}
|
||||
|
||||
for (tr in doc.select("tr")) {
|
||||
val tds = tr.children().filter { it.tagName() == "td" }
|
||||
if (tds.size < 2) continue
|
||||
val first = cellText(tds[0])
|
||||
val second = cellText(tds[1])
|
||||
if (!headerFound && first == "День" && second == "Интервал") {
|
||||
columns = tds.mapIndexedNotNull { index, td ->
|
||||
if (index < 2) return@mapIndexedNotNull null
|
||||
val name = cellText(td.selectFirst("span") ?: td)
|
||||
if (name.isEmpty()) null else Column(name, index)
|
||||
}.distinctBy { it.teacher }
|
||||
teachers = columns.map { it.teacher }
|
||||
headerFound = true
|
||||
continue
|
||||
}
|
||||
val dayMatch = DAY_REGEX.matchEntire(first)
|
||||
val intervalMatch = INTERVAL_REGEX.matchEntire(if (dayMatch != null) second else first)
|
||||
if (dayMatch != null) {
|
||||
flushDay()
|
||||
currentDate = LocalDate.parse(dayMatch.groupValues[2], DATE_FMT)
|
||||
currentIntervals = mutableListOf()
|
||||
}
|
||||
if (intervalMatch != null && currentIntervals != null) {
|
||||
parseIntervalRow(tds, intervalMatch, dayMatch != null, columns, currentIntervals!!)
|
||||
}
|
||||
}
|
||||
flushDay()
|
||||
Log.d(TAG, "parse: teachers=$teachers days=${days.size}")
|
||||
for (day in days) {
|
||||
Log.d(TAG, " parse ${day.date}: intervals=${day.intervals.size}")
|
||||
for (slot in day.intervals) {
|
||||
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
|
||||
val teachersInSlot = slot.lessons.map { it.teacher }.distinct()
|
||||
Log.d(
|
||||
TAG,
|
||||
" slot ${slot.start}-${slot.end} total=${slot.lessons.size} meltsov=${mel.size} teachers=$teachersInSlot"
|
||||
)
|
||||
mel.forEach { l ->
|
||||
Log.d(TAG, " mel: room=${l.room} group=${l.group} subj=${l.subject} type=${l.type} raw=${l.raw}")
|
||||
}
|
||||
}
|
||||
}
|
||||
return ParsedTeacherSchedule(teachers, days)
|
||||
}
|
||||
|
||||
private fun parseIntervalRow(
|
||||
tds: List<Element>,
|
||||
intervalMatch: MatchResult,
|
||||
dayRow: Boolean,
|
||||
columns: List<Column>,
|
||||
intervals: MutableList<TimeSlot>
|
||||
) {
|
||||
val start = LocalTime.parse(intervalMatch.groupValues[1])
|
||||
val end = LocalTime.parse(intervalMatch.groupValues[2])
|
||||
val lessons = mutableListOf<Lesson>()
|
||||
for (column in columns) {
|
||||
// Ряд с днём содержит обе ведущие ячейки (День, Интервал) и совпадает
|
||||
// по индексам с шапкой; ряд только с интервалом пропускает ячейку дня
|
||||
// (rowspan) — его ячейки сдвинуты на одну позицию влево.
|
||||
val dataIndex = if (dayRow) column.headerIndex else column.headerIndex - 1
|
||||
val cell = tds.getOrNull(dataIndex) ?: continue
|
||||
lessons += parseCell(cell, column.teacher)
|
||||
}
|
||||
if (lessons.isNotEmpty()) intervals.add(TimeSlot(start, end, lessons))
|
||||
}
|
||||
|
||||
private fun parseCell(cell: Element, teacher: String): List<Lesson> {
|
||||
val raw = cellText(cell)
|
||||
if (raw.isEmpty()) return emptyList()
|
||||
val room = TextPatterns.ROOM_REGEX.find(raw)?.value
|
||||
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.find(raw)
|
||||
val type = LessonType.fromRaw(typeMatch?.value?.trim() ?: "")
|
||||
val segments = raw.split(SPLIT_BY_GROUP).map { it.trim() }.filter { it.isNotEmpty() }
|
||||
val groupSegments = segments.filter { GROUP_TOKEN.containsMatchIn(it) }
|
||||
if (groupSegments.isEmpty()) {
|
||||
return listOf(
|
||||
Lesson(
|
||||
raw = raw,
|
||||
room = room,
|
||||
subject = subjectOf(raw, room, typeMatch),
|
||||
type = type,
|
||||
teacher = teacher
|
||||
)
|
||||
)
|
||||
}
|
||||
val subject = subjectOf(raw, room, typeMatch)
|
||||
return groupSegments.flatMap { segment ->
|
||||
val groups = GROUP_TOKEN.findAll(segment).map { it.value }.distinct().toList()
|
||||
val subgroup = TextPatterns.SUBGROUP_REGEX.find(segment)?.value
|
||||
groups.map { group ->
|
||||
Lesson(
|
||||
raw = segment,
|
||||
room = room,
|
||||
group = group,
|
||||
subgroup = subgroup,
|
||||
subject = subject,
|
||||
type = type,
|
||||
teacher = teacher
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subjectOf(raw: String, room: String?, typeMatch: MatchResult?): String? {
|
||||
val end = typeMatch?.range?.first ?: GROUP_TOKEN.find(raw)?.range?.first ?: raw.length
|
||||
val prefix = raw.substring(0, end).trim()
|
||||
val stripped = room?.let { prefix.removePrefix(it).trim() } ?: prefix
|
||||
return stripped.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun cellText(el: Element): String = el.text().replace('\u00A0', ' ').trim()
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package agzam.vyatsu.data.remote
|
||||
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Department
|
||||
import agzam.vyatsu.domain.model.ParsedDepartments
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Стриминговый парсер страницы преподавателей: читает тело ответа по чанкам и
|
||||
* разбирает его на лету, не материализуя весь HTML в память.
|
||||
* Страница teacher.html весит ~2.4MB, поэтому весь документ грузить нельзя.
|
||||
*/
|
||||
object TeachersParser {
|
||||
|
||||
private val fakNameTag = Regex("""<div[^>]*class="fak_name"""")
|
||||
private val fakIdInTag = Regex("""data-fak_id="(\d+)"""")
|
||||
private val kafPeriodTag = Regex("""<div[^>]*class="kafPeriod"""")
|
||||
private val kafIdInTag = Regex("""data-kaf_period_id="(\d+)"""")
|
||||
private val prepodLink = Regex("""href="/reports/schedule/prepod/(\d+)_([12])_(\d{8})_(\d{8})\.html"""")
|
||||
private val tagStrip = Regex("""<[^>]*>""")
|
||||
|
||||
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 — жёсткий потолок буфера
|
||||
|
||||
fun parseStream(input: InputStream): ParsedDepartments {
|
||||
val reader = input.bufferedReader(Charsets.UTF_8)
|
||||
val buf = CharArray(CHUNK_SIZE)
|
||||
val pending = StringBuilder(CHUNK_SIZE * 2)
|
||||
val departments = ArrayList<Department>()
|
||||
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
||||
var faculty = ""
|
||||
|
||||
fun addRanges(text: String) {
|
||||
for (m in prepodLink.findAll(text)) {
|
||||
val departmentId = m.groupValues[1]
|
||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
||||
ranges.getOrPut(departmentId) {
|
||||
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
|
||||
}.getOrPut(season) { ArrayList() }.add(range)
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
val n = reader.read(buf)
|
||||
if (n < 0) break
|
||||
pending.append(buf, 0, n)
|
||||
|
||||
var pos = 0
|
||||
while (pos < pending.length) {
|
||||
val divAt = pending.indexOf("<div", pos)
|
||||
val aAt = pending.indexOf("<a", pos)
|
||||
val tagStart = when {
|
||||
divAt < 0 -> aAt
|
||||
aAt < 0 -> divAt
|
||||
else -> minOf(divAt, aAt)
|
||||
}
|
||||
if (tagStart < 0) {
|
||||
pos = keepPartialTagTail(pending, pos)
|
||||
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 && fakNameTag.containsMatchIn(tagText)
|
||||
val isKafPeriod = isDiv && kafPeriodTag.containsMatchIn(tagText)
|
||||
if (isFakName || isKafPeriod) {
|
||||
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
|
||||
if (closeDiv < 0) break
|
||||
addRanges(tagText)
|
||||
val content = pending.substring(tagEnd + 1, closeDiv)
|
||||
if (isFakName) {
|
||||
val stripped = tagStrip.replace(content, "").trim()
|
||||
if (stripped.isNotEmpty()) faculty = FacultyNames.shortname(stripped)
|
||||
} else {
|
||||
val m = kafIdInTag.find(tagText)
|
||||
if (m != null) {
|
||||
departments += Department(
|
||||
departmentId = m.groupValues[1].dropLast(1),
|
||||
name = tagStrip.replace(content, "").trim(),
|
||||
faculty = faculty
|
||||
)
|
||||
}
|
||||
}
|
||||
pos = closeDiv + 6
|
||||
} else {
|
||||
addRanges(tagText)
|
||||
pos = tagEnd + 1
|
||||
}
|
||||
|
||||
if (pos > MAX_SLICE) {
|
||||
pending.delete(0, pos)
|
||||
pos = 0
|
||||
}
|
||||
}
|
||||
if (pos > 0 && pos <= pending.length) {
|
||||
pending.delete(0, pos)
|
||||
}
|
||||
if (pending.length > MAX_PENDING) {
|
||||
pending.delete(0, pending.length - MAX_SLICE)
|
||||
}
|
||||
}
|
||||
return ParsedDepartments(departments, 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
|
||||
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
|
||||
*/
|
||||
private fun keepPartialTagTail(pending: CharSequence, from: Int): Int {
|
||||
var i = from
|
||||
while (i < pending.length) {
|
||||
val c = pending[i]
|
||||
if (c == '<') {
|
||||
val tail = pending.substring(i, pending.length)
|
||||
if (tail == "<" || tail == "<d" || tail == "<di" || tail == "<a") return i
|
||||
}
|
||||
i++
|
||||
}
|
||||
return pending.length
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,7 @@ object VyatsuHttpClient {
|
||||
|
||||
suspend fun getText(url: String): String = withContext(Dispatchers.IO) {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
|
||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||
resp.body!!.string()
|
||||
}
|
||||
@@ -29,10 +30,21 @@ object VyatsuHttpClient {
|
||||
|
||||
suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
|
||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||
resp.body!!.bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает потоковое тело ответа. Вызывающий обязан закрыть
|
||||
* возвращённый [InputStream], чтобы вернуть соединение в пул.
|
||||
*/
|
||||
suspend fun openStream(url: String): InputStream = withContext(Dispatchers.IO) {
|
||||
val resp = client.newCall(Request.Builder().url(url).build()).execute()
|
||||
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
|
||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||
resp.body!!.byteStream()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -10,4 +10,9 @@ object VyatsuUrls {
|
||||
|
||||
fun groupPdfUrl(groupId: String, season: Season, range: DateRange): String = "$PDF_BASEURL/${groupId}_${season.apiKey}_${range.first}_${range.second}.pdf"
|
||||
|
||||
const val TEACHER_PAGE = "https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/teacher.html"
|
||||
const val PREPOD_BASEURL = "https://www.vyatsu.ru/reports/schedule/prepod"
|
||||
|
||||
fun teacherPrepodUrl(departmentId: String, season: Season, range: DateRange): String = "$PREPOD_BASEURL/${departmentId}_${season.apiKey}_${range.first}_${range.second}.html"
|
||||
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
||||
|
||||
private val svgBuilder = StringBuilder()
|
||||
private val currentPath = StringBuilder()
|
||||
private val tableSvg = StringBuilder()
|
||||
val tableSvg = StringBuilder()
|
||||
|
||||
|
||||
private val currentSubPathLines = mutableListOf<PdfLine>()
|
||||
@@ -52,7 +52,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
||||
val width = mediaBox.width
|
||||
val height = mediaBox.height
|
||||
return """
|
||||
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style='font-size: 50%;'>
|
||||
<g>${svgBuilder}</g>
|
||||
<g>${tableSvg}</g>
|
||||
</svg>
|
||||
@@ -211,7 +211,7 @@ class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
|
||||
val bottom = ys[ii+1]
|
||||
cells.add(TableCell(left, top, right-left, bottom-top))
|
||||
|
||||
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f" else "#0"}${if (ii % 2 == 0) "f0" else "00"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
||||
tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
|
||||
}
|
||||
columns.add(TableColumn(left, right-left, cells))
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package agzam.vyatsu.data.remote.pdf
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.Lesson
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import android.util.Log
|
||||
import com.tom_roush.pdfbox.pdmodel.PDDocument
|
||||
@@ -35,6 +36,8 @@ object PdfScheduleParser {
|
||||
}
|
||||
|
||||
val days = mutableListOf<Day>()
|
||||
var group: String? = null
|
||||
|
||||
try {
|
||||
for (pageIndex in 0 until doc.numberOfPages) {
|
||||
Log.v(TAG, "============ Page $pageIndex ============")
|
||||
@@ -45,12 +48,6 @@ object PdfScheduleParser {
|
||||
svg.processPage(page)
|
||||
val cols = svg.asTable()
|
||||
|
||||
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
||||
Log.v(SVG_TAG, "====")
|
||||
for(line in svg.asSvg().split("\n")) {
|
||||
Log.v(SVG_TAG, line)
|
||||
}
|
||||
|
||||
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
|
||||
|
||||
// Парсинг текста
|
||||
@@ -60,6 +57,11 @@ object PdfScheduleParser {
|
||||
textStripper.getText(doc)
|
||||
val texts = textStripper.blocks
|
||||
|
||||
for(text in texts) {
|
||||
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
|
||||
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
|
||||
}
|
||||
|
||||
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
|
||||
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
|
||||
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
|
||||
@@ -100,10 +102,21 @@ object PdfScheduleParser {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (dayCell in daysColumn.cells) {
|
||||
val day = content(dayCell).split(' ')
|
||||
if(day.size != 2) continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
||||
if(day.size == 1) {
|
||||
Log.v(TAG, "day.first(): ${day.first()}")
|
||||
}
|
||||
if(day.size == 1 && day.first() == "День") {
|
||||
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
|
||||
group = content(groupCell)
|
||||
Log.v(TAG, "Group: $group")
|
||||
continue
|
||||
}
|
||||
if(day.size != 2) {
|
||||
Log.w(TAG, "Day data is not in format <day> <date>: $day")
|
||||
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
|
||||
}
|
||||
|
||||
val date = dateFrom(day[1]) ?: continue
|
||||
val intervals = mutableListOf<TimeSlot>()
|
||||
@@ -124,7 +137,55 @@ object PdfScheduleParser {
|
||||
val content = content(lessonCell)
|
||||
Log.v(TAG, "- $content")
|
||||
// TODO: парсинг контента лекции
|
||||
lessons.add(Lesson(raw=content))
|
||||
|
||||
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
|
||||
var found = 0
|
||||
var currentStart = 0
|
||||
for (roomMatch in TextPatterns.ROOM_REGEX.findAll(content)) {
|
||||
found++
|
||||
|
||||
val raw = content.substring(currentStart, roomMatch.range.last + 1)
|
||||
currentStart = roomMatch.range.last + 1
|
||||
|
||||
// Предполагаемый формат: [группа], [XX подгруппа] <предмет> <тип> [id + тип] <ФИО> <кабинет>
|
||||
|
||||
val room = roomMatch.value
|
||||
|
||||
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.findAll(raw).toList()
|
||||
if(typeMatch.isEmpty()) {
|
||||
lessons.add(Lesson(
|
||||
raw=raw,
|
||||
room=room,
|
||||
))
|
||||
continue
|
||||
}
|
||||
var subject = raw.substring(0, typeMatch.first().range.first).trim() // TODO: сейчас [группа], [XX подгруппа] <предмет> - можно достать подгруппу
|
||||
val type = LessonType.fromRaw(typeMatch.first().value.trim())
|
||||
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
|
||||
var subgroup: String? = null
|
||||
Log.d(TAG, "$subject $group")
|
||||
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
|
||||
subject = subject.substring(group.length + 1).trim()
|
||||
val subgroupMatch = TextPatterns.SUBGROUP_REGEX.find(subject)
|
||||
if(subgroupMatch != null) {
|
||||
subgroup = subgroupMatch.value.trim()
|
||||
subject = subject.removeRange(subgroupMatch.range).trim()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
lessons.add(Lesson(
|
||||
raw=raw,
|
||||
subject=subject,
|
||||
room=room,
|
||||
type=type,
|
||||
teacher=teacher,
|
||||
subgroup=subgroup
|
||||
))
|
||||
}
|
||||
|
||||
if(found == 0) lessons.add(Lesson(raw=content))
|
||||
|
||||
}
|
||||
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
|
||||
}
|
||||
@@ -133,6 +194,19 @@ object PdfScheduleParser {
|
||||
for (text in texts.filter { it.rotation != 0f }) {
|
||||
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
|
||||
}
|
||||
|
||||
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
||||
Log.v(SVG_TAG, "====")
|
||||
Log.v(SVG_TAG+ "-" + (pageIndex+1), "====")
|
||||
for(line in svg.asSvg().split("\n")) {
|
||||
if(line.length > 250) {
|
||||
Log.v(SVG_TAG, "length: " + line.length)
|
||||
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
|
||||
continue
|
||||
}
|
||||
Log.v(SVG_TAG, line)
|
||||
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Parsing error", e)
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.tom_roush.pdfbox.text.TextPosition
|
||||
* Парсер блоков текста из PDF
|
||||
* @author Agzam
|
||||
*/
|
||||
|
||||
class PdfTextParser : PDFTextStripper() {
|
||||
|
||||
data class TextBlock(
|
||||
@@ -25,21 +26,51 @@ class PdfTextParser : PDFTextStripper() {
|
||||
}
|
||||
|
||||
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
|
||||
if (!string.isNullOrBlank() && !textPositions.isNullOrEmpty()) {
|
||||
val first = textPositions.first()
|
||||
val last = textPositions.last()
|
||||
if (textPositions.isNullOrEmpty()) return
|
||||
|
||||
val matrix = first.textMatrix
|
||||
val rotation = first.dir
|
||||
val currentWordChars = mutableListOf<TextPosition>()
|
||||
|
||||
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
|
||||
for (tp in textPositions) {
|
||||
val unicode = tp.unicode
|
||||
|
||||
val x = matrix.translateX
|
||||
val y = matrix.translateY - first.heightDir
|
||||
|
||||
val height = first.heightDir
|
||||
|
||||
blocks.add(TextBlock(string.trim(), x, currentPage.mediaBox.height - y - height, totalWidth, height, rotation))
|
||||
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
|
||||
addWordBlock(currentWordChars)
|
||||
currentWordChars.clear()
|
||||
} else {
|
||||
currentWordChars.add(tp)
|
||||
}
|
||||
}
|
||||
|
||||
addWordBlock(currentWordChars)
|
||||
}
|
||||
|
||||
private fun addWordBlock(wordChars: List<TextPosition>) {
|
||||
if (wordChars.isEmpty()) return
|
||||
|
||||
val first = wordChars.first()
|
||||
val last = wordChars.last()
|
||||
|
||||
val wordText = wordChars.joinToString("") { it.unicode }.trim()
|
||||
if (wordText.isEmpty()) return
|
||||
|
||||
val matrix = first.textMatrix
|
||||
val rotation = first.dir
|
||||
|
||||
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
|
||||
|
||||
val x = matrix.translateX
|
||||
val y = matrix.translateY - first.heightDir
|
||||
val height = first.heightDir
|
||||
|
||||
blocks.add(
|
||||
TextBlock(
|
||||
text = wordText,
|
||||
x = x,
|
||||
y = currentPage.mediaBox.height - y - height,
|
||||
width = totalWidth,
|
||||
height = height,
|
||||
rotation = rotation
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package agzam.vyatsu.data.remote.pdf
|
||||
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
|
||||
object TextPatterns {
|
||||
|
||||
val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
|
||||
|
||||
val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" })
|
||||
|
||||
val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package agzam.vyatsu.data.remote.update
|
||||
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import com.squareup.moshi.Moshi
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GiteaRelease(
|
||||
@Json(name = "tag_name") val tagName: String?,
|
||||
val name: String?,
|
||||
val body: String?,
|
||||
val assets: List<GiteaAsset> = emptyList()
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class GiteaAsset(
|
||||
val name: String?,
|
||||
@Json(name = "browser_download_url") val browserDownloadUrl: String?
|
||||
)
|
||||
|
||||
class GiteaReleaseApi(
|
||||
private val client: OkHttpClient,
|
||||
private val baseUrl: String = "https://vantablacktea.ddns.net",
|
||||
private val repoPath: String = "agzam/vyatsu-schedule"
|
||||
) {
|
||||
|
||||
private val moshi = Moshi.Builder().build()
|
||||
private val releaseAdapter = moshi.adapter(GiteaRelease::class.java)
|
||||
|
||||
/**
|
||||
* Возвращает последний релиз или null, если релизов ещё нет (404).
|
||||
*/
|
||||
suspend fun latestRelease(): GiteaRelease? {
|
||||
val url = "$baseUrl/api/v1/repos/$repoPath/releases/latest"
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
|
||||
return client.newCall(request).execute().use { resp ->
|
||||
when {
|
||||
resp.code == 404 -> null
|
||||
!resp.isSuccessful -> throw IOException("HTTP ${resp.code} при запросе релиза")
|
||||
else -> {
|
||||
val body = resp.body ?: throw IOException("Пустой ответ сервера")
|
||||
releaseAdapter.fromJson(body.source())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun apkDownloadUrl(release: GiteaRelease): String? =
|
||||
release.assets
|
||||
.firstOrNull { it.name?.endsWith(".apk", ignoreCase = true) == true }
|
||||
?.browserDownloadUrl
|
||||
}
|
||||
@@ -2,16 +2,18 @@ package agzam.vyatsu.data.repository
|
||||
|
||||
import agzam.vyatsu.data.local.GroupDao
|
||||
import agzam.vyatsu.data.local.GroupEntity
|
||||
import agzam.vyatsu.data.local.GroupPeriodEntity
|
||||
import agzam.vyatsu.data.remote.GroupsParser
|
||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||
import agzam.vyatsu.data.remote.VyatsuUrls
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class GroupRepositoryImpl(
|
||||
private val groupDao: GroupDao,
|
||||
@@ -40,29 +42,36 @@ class GroupRepositoryImpl(
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
val html = VyatsuHttpClient.getText(VyatsuUrls.GROUPS_PAGE)
|
||||
val groups = GroupsParser.parseGroups(html)
|
||||
val ranges = GroupsParser.parseDateRanges(html)
|
||||
val entities = groups.map { group ->
|
||||
val range = ranges[group.id]
|
||||
val autumn = range?.get(Season.AUTUMN)?.lastOrNull()
|
||||
val spring = range?.get(Season.SPRING)?.lastOrNull()
|
||||
GroupEntity(
|
||||
id = group.id,
|
||||
name = group.name,
|
||||
faculty = group.faculty,
|
||||
autumnFirst = autumn?.first?.let(::epochDay),
|
||||
autumnSecond = autumn?.second?.let(::epochDay),
|
||||
springFirst = spring?.first?.let(::epochDay),
|
||||
springSecond = spring?.second?.let(::epochDay),
|
||||
lastSyncedAt = now()
|
||||
)
|
||||
val parsed = withContext(Dispatchers.IO) {
|
||||
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
|
||||
}
|
||||
groupDao.insertAll(entities)
|
||||
val now = now()
|
||||
groupDao.insertAll(
|
||||
parsed.groups.map { group ->
|
||||
GroupEntity(
|
||||
id = group.id,
|
||||
name = group.name,
|
||||
faculty = group.faculty,
|
||||
lastSyncedAt = now
|
||||
)
|
||||
}
|
||||
)
|
||||
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 GroupEntity.toDomain(): Group = Group(
|
||||
|
||||
@@ -13,4 +13,20 @@ class PreferencesRepositoryImpl(
|
||||
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
|
||||
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
||||
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
|
||||
|
||||
override suspend fun selectedTeacherId(): String? = dao.selectedTeacherId()
|
||||
override suspend fun setSelectedTeacherId(id: String) = dao.setSelectedTeacherId(id)
|
||||
override suspend fun selectedTeacherWeekStart(): String? = dao.selectedTeacherWeekStart()
|
||||
override suspend fun setSelectedTeacherWeekStart(weekStart: String) =
|
||||
dao.setSelectedTeacherWeekStart(weekStart)
|
||||
override suspend fun selectedTeacherDayDate(): String? = dao.selectedTeacherDayDate()
|
||||
override suspend fun setSelectedTeacherDayDate(date: String) =
|
||||
dao.setSelectedTeacherDayDate(date)
|
||||
override suspend fun clearSelectedTeacher() = dao.clearSelectedTeacher()
|
||||
|
||||
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() == "true"
|
||||
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
|
||||
|
||||
override suspend fun autoUpdateAsked(): Boolean = dao.autoUpdateAsked() == "true"
|
||||
override suspend fun setAutoUpdateAsked(asked: Boolean) = dao.setAutoUpdateAsked(asked.toString())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agzam.vyatsu.data.repository
|
||||
|
||||
import agzam.vyatsu.data.local.DayEntity
|
||||
import agzam.vyatsu.data.local.GroupPeriodEntity
|
||||
import agzam.vyatsu.data.local.LessonEntity
|
||||
import agzam.vyatsu.data.local.ScheduleDao
|
||||
import agzam.vyatsu.data.local.ScheduleEntity
|
||||
@@ -51,15 +52,38 @@ class ScheduleRepositoryImpl(
|
||||
Log.w(TAG, "sync: group $groupId not found in DB")
|
||||
return null
|
||||
}
|
||||
val season = ScheduleDates.currentSeason()
|
||||
val range = dateRangeFor(group, season) ?: run {
|
||||
Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
|
||||
val today = ScheduleDates.moscowToday().toEpochDay()
|
||||
val periods = groupDao.getCurrentAndFuturePeriods(groupId, today)
|
||||
if (periods.isEmpty()) {
|
||||
Log.w(TAG, "sync: no current/future periods for group ${group.id}")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
|
||||
|
||||
return try {
|
||||
val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range)
|
||||
for (period in periods) {
|
||||
syncPeriod(group, period)
|
||||
}
|
||||
|
||||
scheduleDao.trim(groupId, MAX_PERIODS)
|
||||
|
||||
return getSchedule(groupId)
|
||||
}
|
||||
|
||||
private suspend fun syncPeriod(group: GroupEntity, period: GroupPeriodEntity) {
|
||||
val season = period.season
|
||||
val range = DateRange(
|
||||
LocalDate.ofEpochDay(period.weekStart).format(RANGE_FMT),
|
||||
LocalDate.ofEpochDay(period.weekEnd).format(RANGE_FMT)
|
||||
)
|
||||
val existing = scheduleDao.getSchedules(group.id)
|
||||
.firstOrNull { it.season == season && it.weekStart == period.weekStart }
|
||||
val isCurrent = ScheduleDates.moscowToday().toEpochDay() in period.weekStart..period.weekEnd
|
||||
if (existing != null && !isCurrent && now() - existing.lastSyncedAt < STALE_WINDOW_MS) {
|
||||
Log.d(TAG, "sync: period $range up to date, skip")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val pdfUrl = VyatsuUrls.groupPdfUrl(group.id, season, range)
|
||||
Log.d(TAG, "sync: downloading $pdfUrl")
|
||||
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
|
||||
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
|
||||
@@ -67,29 +91,19 @@ class ScheduleRepositoryImpl(
|
||||
val days = PdfScheduleParser.parse(bytes)
|
||||
Log.d(TAG, "sync: parsed ${days.size} days")
|
||||
|
||||
val weekStart = ScheduleDates.asDate(range.first).toEpochDay()
|
||||
val weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
|
||||
|
||||
val existing = scheduleDao.getSchedules(groupId)
|
||||
.firstOrNull { it.season == season && it.weekStart == weekStart }
|
||||
val scheduleId = if (existing != null) existing.id
|
||||
else scheduleDao.insertSchedule(
|
||||
val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
|
||||
ScheduleEntity(
|
||||
groupId = groupId,
|
||||
groupId = group.id,
|
||||
season = season,
|
||||
weekStart = weekStart,
|
||||
weekEnd = weekEnd,
|
||||
weekStart = period.weekStart,
|
||||
weekEnd = period.weekEnd,
|
||||
lastSyncedAt = now()
|
||||
)
|
||||
)
|
||||
replaceDays(scheduleId, days)
|
||||
|
||||
scheduleDao.trim(groupId, MAX_PERIODS)
|
||||
|
||||
getSchedule(groupId)
|
||||
Log.d(TAG, "sync: period $range synced")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
|
||||
null
|
||||
Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +171,7 @@ class ScheduleRepositoryImpl(
|
||||
val lessonsByDay = lessonEntities.groupBy { it.dayId }
|
||||
val days = dayEntities
|
||||
.sortedBy { it.date }
|
||||
.distinctBy { it.date }
|
||||
.map { day ->
|
||||
val lessons = lessonsByDay[day.id].orEmpty()
|
||||
.groupBy { it.slotIndex }
|
||||
@@ -186,30 +201,13 @@ class ScheduleRepositoryImpl(
|
||||
teacher = teacher
|
||||
)
|
||||
|
||||
private fun dateRangeFor(group: GroupEntity, season: Season): DateRange? {
|
||||
return when (season) {
|
||||
Season.AUTUMN -> if (group.autumnFirst != null && group.autumnSecond != null) {
|
||||
DateRange(
|
||||
LocalDate.ofEpochDay(group.autumnFirst).format(RANGE_FMT),
|
||||
LocalDate.ofEpochDay(group.autumnSecond).format(RANGE_FMT)
|
||||
)
|
||||
} else null
|
||||
Season.SPRING -> if (group.springFirst != null && group.springSecond != null) {
|
||||
DateRange(
|
||||
LocalDate.ofEpochDay(group.springFirst).format(RANGE_FMT),
|
||||
LocalDate.ofEpochDay(group.springSecond).format(RANGE_FMT)
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
|
||||
|
||||
private fun now(): Long = System.currentTimeMillis()
|
||||
|
||||
companion object {
|
||||
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
||||
private const val MAX_PERIODS = 3
|
||||
private const val MAX_PERIODS = 24
|
||||
private const val TAG = "ScheduleRepo"
|
||||
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package agzam.vyatsu.data.repository
|
||||
|
||||
import agzam.vyatsu.data.local.DepartmentEntity
|
||||
import agzam.vyatsu.data.local.DepartmentPeriodEntity
|
||||
import agzam.vyatsu.data.local.TeacherDao
|
||||
import agzam.vyatsu.data.remote.TeachersParser
|
||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||
import agzam.vyatsu.data.remote.VyatsuUrls
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.Department
|
||||
import agzam.vyatsu.domain.repository.TeacherRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class TeacherRepositoryImpl(
|
||||
private val teacherDao: TeacherDao,
|
||||
) : TeacherRepository {
|
||||
|
||||
override fun observeDepartments(): Flow<List<Department>> =
|
||||
teacherDao.observeDepartments().map { list -> list.map { it.toDomain() } }
|
||||
|
||||
override suspend fun getDepartments(): List<Department> =
|
||||
teacherDao.observeDepartments().first().map { it.toDomain() }
|
||||
|
||||
override suspend fun getFaculties(): List<String> = teacherDao.getFaculties()
|
||||
|
||||
override suspend fun refreshIfStale(): Boolean {
|
||||
val last = teacherDao.maxSyncedAt()
|
||||
if (last != null && now() - last < STALE_WINDOW_MS) {
|
||||
return true
|
||||
}
|
||||
refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
override suspend fun forceRefresh(): Boolean {
|
||||
refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
val parsed = withContext(Dispatchers.IO) {
|
||||
VyatsuHttpClient.openStream(VyatsuUrls.TEACHER_PAGE).use(TeachersParser::parseStream)
|
||||
}
|
||||
val now = now()
|
||||
teacherDao.insertAll(
|
||||
parsed.departments.map { department ->
|
||||
DepartmentEntity(
|
||||
departmentId = department.departmentId,
|
||||
name = department.name,
|
||||
faculty = department.faculty,
|
||||
lastSyncedAt = now
|
||||
)
|
||||
}
|
||||
)
|
||||
val periods = parsed.departments.flatMap { department ->
|
||||
parsed.dateRanges[department.departmentId].orEmpty().flatMap { (season, ranges) ->
|
||||
ranges.map { range ->
|
||||
DepartmentPeriodEntity(
|
||||
departmentId = department.departmentId,
|
||||
season = season,
|
||||
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
|
||||
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
teacherDao.deleteAllPeriods()
|
||||
teacherDao.insertPeriods(periods)
|
||||
}
|
||||
|
||||
private fun now(): Long = System.currentTimeMillis()
|
||||
|
||||
private fun DepartmentEntity.toDomain(): Department = Department(
|
||||
departmentId = departmentId,
|
||||
name = name,
|
||||
faculty = faculty
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package agzam.vyatsu.data.repository
|
||||
|
||||
import agzam.vyatsu.data.local.DepartmentPeriodEntity
|
||||
import agzam.vyatsu.data.local.TeacherDao
|
||||
import agzam.vyatsu.data.local.TeacherDayEntity
|
||||
import agzam.vyatsu.data.local.TeacherLessonEntity
|
||||
import agzam.vyatsu.data.local.TeacherLessonWithDepartment
|
||||
import agzam.vyatsu.data.local.TeacherScheduleDao
|
||||
import agzam.vyatsu.data.local.TeacherScheduleEntity
|
||||
import agzam.vyatsu.data.remote.TeacherScheduleParser
|
||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||
import agzam.vyatsu.data.remote.VyatsuUrls
|
||||
import agzam.vyatsu.domain.model.BulkSyncProgress
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.Lesson
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.Teacher
|
||||
import agzam.vyatsu.domain.model.TeacherSchedule
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class TeacherScheduleRepositoryImpl(
|
||||
private val scheduleDao: TeacherScheduleDao,
|
||||
private val teacherDao: TeacherDao
|
||||
) : TeacherScheduleRepository {
|
||||
|
||||
private val syncMutex = Mutex()
|
||||
|
||||
override fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> =
|
||||
combine(
|
||||
scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()),
|
||||
scheduleDao.observeLessonsForPeriod(period.season.name, period.weekStart.toEpochDay())
|
||||
) { days, lessons ->
|
||||
buildTeacherSchedule(period, days, lessons)
|
||||
}
|
||||
|
||||
override fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>> =
|
||||
scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay())
|
||||
.map { names -> names.map { name -> Teacher(name = name) } }
|
||||
|
||||
override suspend fun syncPeriod(date: LocalDate, force: Boolean): Flow<BulkSyncProgress> = flow {
|
||||
// Unique index (departmentId, season, weekStart) breaks on interleaved
|
||||
// delete+insert from concurrent syncPeriod() callers.
|
||||
syncMutex.withLock {
|
||||
val targets = withContext(Dispatchers.IO) {
|
||||
teacherDao.getPeriodsForDate(date.toEpochDay())
|
||||
}
|
||||
if (targets.isEmpty()) {
|
||||
emit(BulkSyncProgress(0, 0, 0))
|
||||
return@withLock
|
||||
}
|
||||
|
||||
val now = now()
|
||||
var skippedCount = 0
|
||||
val nonSkipped = mutableListOf<DepartmentPeriodEntity>()
|
||||
for (target in targets) {
|
||||
val last = withContext(Dispatchers.IO) {
|
||||
scheduleDao.lastSyncedAt(target.departmentId, target.season.name, target.weekStart)
|
||||
}
|
||||
val shouldSkip = !force && last != null && now - last < STALE_WINDOW_MS
|
||||
if (shouldSkip) {
|
||||
skippedCount++
|
||||
} else {
|
||||
nonSkipped.add(target)
|
||||
}
|
||||
}
|
||||
|
||||
emit(BulkSyncProgress(total = targets.size, done = skippedCount, failed = 0))
|
||||
|
||||
val total = targets.size
|
||||
val semaphore = Semaphore(PARALLELISM)
|
||||
val jobs = coroutineScope {
|
||||
nonSkipped.map { target ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { syncDepartment(target) }
|
||||
true
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"sync: failed for department ${target.departmentId} " +
|
||||
"season ${target.season} week ${target.weekStart} " +
|
||||
"(${e::class.simpleName}: ${e.message})",
|
||||
e
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var done = skippedCount
|
||||
var failed = 0
|
||||
for (job in jobs) {
|
||||
if (job.await()) done++ else failed++
|
||||
emit(BulkSyncProgress(total, done + failed, failed))
|
||||
}
|
||||
|
||||
withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun hasData(period: SchedulePeriod): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay())
|
||||
}
|
||||
|
||||
override suspend fun weeksFor(date: LocalDate): List<SchedulePeriod> =
|
||||
withContext(Dispatchers.IO) {
|
||||
teacherDao.getPeriodsForDate(date.toEpochDay())
|
||||
.distinctBy { it.season to (it.weekStart to it.weekEnd) }
|
||||
.flatMap { window ->
|
||||
val windowStart = LocalDate.ofEpochDay(window.weekStart)
|
||||
val windowEnd = LocalDate.ofEpochDay(window.weekEnd)
|
||||
generateSequence(
|
||||
windowStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||
) { it.plusWeeks(1) }
|
||||
.takeWhile { it <= windowEnd }
|
||||
.map { monday ->
|
||||
val weekEnd = monday.plusDays(6)
|
||||
SchedulePeriod(
|
||||
weekStart = monday,
|
||||
weekEnd = if (weekEnd.isAfter(windowEnd)) windowEnd else weekEnd,
|
||||
season = window.season
|
||||
)
|
||||
}
|
||||
}
|
||||
.distinctBy { it.season to it.weekStart }
|
||||
.sortedBy { it.weekStart }
|
||||
}
|
||||
|
||||
override suspend fun clearAll() = withContext(Dispatchers.IO) {
|
||||
scheduleDao.deleteAll()
|
||||
}
|
||||
|
||||
private suspend fun syncDepartment(target: DepartmentPeriodEntity) {
|
||||
val season = target.season
|
||||
val range = DateRange(
|
||||
LocalDate.ofEpochDay(target.weekStart).format(RANGE_FMT),
|
||||
LocalDate.ofEpochDay(target.weekEnd).format(RANGE_FMT)
|
||||
)
|
||||
val url = VyatsuUrls.teacherPrepodUrl(target.departmentId, season, range)
|
||||
Log.d(TAG, "syncDepartment: dept=${target.departmentId} url=$url")
|
||||
val html = VyatsuHttpClient.getBytes(url).toString(Charsets.UTF_8)
|
||||
val parsed = TeacherScheduleParser.parse(html)
|
||||
|
||||
Log.d(TAG, "syncDepartment: dept=${target.departmentId} parsedDays=${parsed.days.size}")
|
||||
parsed.days.forEach { day ->
|
||||
day.intervals.forEach { slot ->
|
||||
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
|
||||
if (mel.isNotEmpty()) {
|
||||
Log.d(TAG, "syncDepartment: dept=${target.departmentId} date=${day.date} slot=${slot.start}-${slot.end} melLessons=${mel.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dayEntities = parsed.days.map {
|
||||
TeacherDayEntity(scheduleId = 0, date = it.date.toEpochDay())
|
||||
}
|
||||
val lessonEntities = parsed.days.flatMap { day ->
|
||||
day.intervals.flatMapIndexed { slotIndex, slot ->
|
||||
slot.lessons.mapIndexedNotNull { lessonIndex, lesson ->
|
||||
val teacher = lesson.teacher ?: return@mapIndexedNotNull null
|
||||
TeacherLessonEntity(
|
||||
dayId = 0,
|
||||
date = day.date.toEpochDay(),
|
||||
slotIndex = slotIndex,
|
||||
start = slot.start.toMinutesOfDay(),
|
||||
end = slot.end.toMinutesOfDay(),
|
||||
lessonIndex = lessonIndex,
|
||||
raw = lesson.raw,
|
||||
room = lesson.room,
|
||||
groupName = lesson.group,
|
||||
subgroup = lesson.subgroup,
|
||||
subject = lesson.subject,
|
||||
type = lesson.type,
|
||||
teacher = teacher
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "syncDepartment: dept=${target.departmentId} -> lessons=${lessonEntities.size}")
|
||||
val melLessonEntities = lessonEntities.filter { it.teacher == "Мельцов В.Ю." }
|
||||
melLessonEntities.forEach { le ->
|
||||
Log.d(
|
||||
TAG,
|
||||
"syncDepartment: dept=${target.departmentId} MEL date=${le.date} slotIndex=${le.slotIndex} " +
|
||||
"start=$le.start end=$le.end raw=${le.raw}"
|
||||
)
|
||||
}
|
||||
scheduleDao.replaceSchedule(
|
||||
TeacherScheduleEntity(
|
||||
departmentId = target.departmentId,
|
||||
season = season,
|
||||
weekStart = target.weekStart,
|
||||
weekEnd = target.weekEnd,
|
||||
lastSyncedAt = now()
|
||||
),
|
||||
dayEntities,
|
||||
lessonEntities
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun trimToRecent(keepDistinctPeriods: Int) {
|
||||
val schedules = scheduleDao.getSchedules()
|
||||
schedules
|
||||
.map { it.season to it.weekStart }
|
||||
.distinct()
|
||||
.sortedByDescending { it.second }
|
||||
.drop(keepDistinctPeriods)
|
||||
.forEach { (season, weekStart) ->
|
||||
scheduleDao.deletePeriod(season.name, weekStart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildTeacherSchedule(
|
||||
period: SchedulePeriod,
|
||||
dayEntities: List<TeacherDayEntity>,
|
||||
lessons: List<TeacherLessonWithDepartment>
|
||||
): TeacherSchedule? {
|
||||
val weekDays = dayEntities
|
||||
.filter {
|
||||
it.date >= period.weekStart.toEpochDay() && it.date <= period.weekEnd.toEpochDay()
|
||||
}
|
||||
if (weekDays.isEmpty()) return null
|
||||
val lessonsByDate = lessons.groupBy { it.lesson.date }
|
||||
val days = weekDays
|
||||
.sortedBy { it.date }
|
||||
.distinctBy { it.date }
|
||||
.map { day ->
|
||||
val dayLessons = lessonsByDate[day.date].orEmpty()
|
||||
Log.d(
|
||||
TAG,
|
||||
"build: date=${day.date} totalLessons=${dayLessons.size} " +
|
||||
"groupedByStart=${dayLessons.groupBy { it.lesson.start }.keys.sorted()}"
|
||||
)
|
||||
val slots = dayLessons.groupBy { it.lesson.start }
|
||||
.toSortedMap()
|
||||
.map { (startMin, slotLessons) ->
|
||||
val first = slotLessons.first().lesson
|
||||
val times = slotLessons.map { "${it.lesson.start}-${it.lesson.end}" }.distinct()
|
||||
val teachers = slotLessons.map { it.lesson.teacher }.distinct()
|
||||
Log.d(
|
||||
TAG,
|
||||
"build: date=${day.date} start=$startMin firstEnd=$first.end " +
|
||||
"times=$times teachers=$teachers"
|
||||
)
|
||||
TimeSlot(
|
||||
start = LocalTime.of(startMin / 60, startMin % 60),
|
||||
end = LocalTime.of(first.end / 60, first.end % 60),
|
||||
lessons = slotLessons
|
||||
.sortedBy { it.lesson.lessonIndex }
|
||||
.map { it.toDomain() }
|
||||
)
|
||||
}
|
||||
Day(date = LocalDate.ofEpochDay(day.date), intervals = slots)
|
||||
}
|
||||
return TeacherSchedule(period = period, days = days)
|
||||
}
|
||||
|
||||
private fun TeacherLessonWithDepartment.toDomain(): Lesson = Lesson(
|
||||
raw = lesson.raw,
|
||||
room = lesson.room,
|
||||
group = lesson.groupName,
|
||||
subgroup = lesson.subgroup,
|
||||
subject = lesson.subject,
|
||||
type = lesson.type,
|
||||
teacher = lesson.teacher,
|
||||
department = departmentLabel(departmentId, departmentName)
|
||||
)
|
||||
|
||||
// Названия, не выводимые акронимом из слов (например «топ-уровня в сфере…»)
|
||||
private val DEPARTMENT_LABELS = mapOf(
|
||||
"974" to "Топ ИИ"
|
||||
)
|
||||
|
||||
private fun departmentLabel(departmentId: String, departmentName: String?): String {
|
||||
DEPARTMENT_LABELS[departmentId]?.let { return it }
|
||||
val name = departmentName ?: return "каф. $departmentId"
|
||||
val words = name.removePrefix("Кафедра ").removePrefix("кафедра ")
|
||||
.substringBefore(" (")
|
||||
.split(" ")
|
||||
.filter { it.isNotBlank() && it != "и" }
|
||||
val acronym = words.joinToString("") { it.first().uppercase().toString() }
|
||||
return acronym.ifBlank { "каф. $departmentId" }
|
||||
}
|
||||
|
||||
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
|
||||
|
||||
private fun now(): Long = System.currentTimeMillis()
|
||||
|
||||
companion object {
|
||||
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
||||
private const val PARALLELISM = 2
|
||||
private const val TAG = "TeacherScheduleRepo"
|
||||
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,14 @@ import agzam.vyatsu.data.remote.VyatsuHttpClient
|
||||
import agzam.vyatsu.data.repository.GroupRepositoryImpl
|
||||
import agzam.vyatsu.data.repository.PreferencesRepositoryImpl
|
||||
import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
|
||||
import agzam.vyatsu.data.repository.TeacherRepositoryImpl
|
||||
import agzam.vyatsu.data.repository.TeacherScheduleRepositoryImpl
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||
import agzam.vyatsu.domain.repository.TeacherRepository
|
||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
||||
import agzam.vyatsu.update.AppUpdater
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
|
||||
@@ -18,6 +23,7 @@ class AppContainer(context: Context) {
|
||||
AppDatabase::class.java,
|
||||
"vyatsu_schedule.db"
|
||||
)
|
||||
.addMigrations(AppDatabase.MIGRATION_3_4)
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
|
||||
@@ -37,4 +43,19 @@ class AppContainer(context: Context) {
|
||||
val preferencesRepository: PreferencesRepository by lazy {
|
||||
PreferencesRepositoryImpl(database.preferencesDao())
|
||||
}
|
||||
|
||||
val teacherRepository: TeacherRepository by lazy {
|
||||
TeacherRepositoryImpl(teacherDao = database.teacherDao())
|
||||
}
|
||||
|
||||
val teacherScheduleRepository: TeacherScheduleRepository by lazy {
|
||||
TeacherScheduleRepositoryImpl(
|
||||
scheduleDao = database.teacherScheduleDao(),
|
||||
teacherDao = database.teacherDao()
|
||||
)
|
||||
}
|
||||
|
||||
val appUpdater: AppUpdater by lazy {
|
||||
AppUpdater(context.applicationContext, preferencesRepository)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
data class BulkSyncProgress(
|
||||
val total: Int,
|
||||
val done: Int,
|
||||
val failed: Int
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
/**
|
||||
* Кафедра ВятГУ.
|
||||
* departmentId — идентификатор кафедры из data-kaf_period_id без последней цифры (как в URL).
|
||||
*/
|
||||
data class Department(
|
||||
val departmentId: String,
|
||||
val name: String,
|
||||
val faculty: String
|
||||
)
|
||||
@@ -26,4 +26,6 @@ data class Lesson(
|
||||
/** Преподаватель **/
|
||||
val teacher: String? = null,
|
||||
|
||||
/** Кафедра-источник (только в расписании преподавателей) **/
|
||||
val department: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
data class ParsedDepartments(
|
||||
val departments: List<Department>,
|
||||
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
/**
|
||||
* Преподаватель ВятГУ.
|
||||
* Один и тот же преподаватель может вести занятия в нескольких кафедрах,
|
||||
* поэтому модель не привязана к departmentId — расписание агрегируется
|
||||
* по всем кафедрам периода.
|
||||
*/
|
||||
data class Teacher(
|
||||
val name: String
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
/**
|
||||
* Расписание преподавателя за период, агрегированное по всем кафедрам.
|
||||
*/
|
||||
data class TeacherSchedule(
|
||||
val period: SchedulePeriod,
|
||||
val days: List<Day>
|
||||
) {
|
||||
/**
|
||||
* Возвращает дни периода только с занятиями указанного преподавателя.
|
||||
* Слоты без занятий отбрасываются, порядок [Day] и [TimeSlot] сохраняется.
|
||||
*
|
||||
* Дедупликация — ТОЛЬКО по полному совпадению (дата + слот + аудитория +
|
||||
* группа + предмет + тип + raw): два занятия коллапсируют в одно лишь когда
|
||||
* они идентичны целиком (одно физическое занятие, прочитанное из файлов двух
|
||||
* кафедр). Всё, что отличается хотя бы одним полем, остаётся и показывается.
|
||||
*/
|
||||
fun daysForTeacher(teacherName: String): List<Day> =
|
||||
days.mapNotNull { day ->
|
||||
val seen = HashSet<FullMatchKey>()
|
||||
val intervals = day.intervals.mapNotNull { slot ->
|
||||
val lessons = slot.lessons
|
||||
.filter { it.teacher == teacherName }
|
||||
.filter {
|
||||
seen.add(
|
||||
FullMatchKey(
|
||||
date = day.date,
|
||||
start = slot.start,
|
||||
end = slot.end,
|
||||
room = it.room,
|
||||
group = it.group,
|
||||
subject = it.subject,
|
||||
type = it.type,
|
||||
raw = it.raw
|
||||
)
|
||||
)
|
||||
}
|
||||
if (lessons.isEmpty()) null else slot.copy(lessons = lessons)
|
||||
}
|
||||
if (intervals.isEmpty()) null else day.copy(intervals = intervals)
|
||||
}
|
||||
|
||||
private data class FullMatchKey(
|
||||
val date: LocalDate,
|
||||
val start: LocalTime,
|
||||
val end: LocalTime,
|
||||
val room: String?,
|
||||
val group: String?,
|
||||
val subject: String?,
|
||||
val type: LessonType,
|
||||
val raw: String
|
||||
)
|
||||
}
|
||||
@@ -8,4 +8,18 @@ interface PreferencesRepository {
|
||||
suspend fun setSelectedWeekStart(weekStart: String)
|
||||
suspend fun selectedDayDate(): String?
|
||||
suspend fun setSelectedDayDate(date: String)
|
||||
|
||||
suspend fun selectedTeacherId(): String? = null
|
||||
suspend fun setSelectedTeacherId(id: String) = Unit
|
||||
suspend fun selectedTeacherWeekStart(): String? = null
|
||||
suspend fun setSelectedTeacherWeekStart(weekStart: String) = Unit
|
||||
suspend fun selectedTeacherDayDate(): String? = null
|
||||
suspend fun setSelectedTeacherDayDate(date: String) = Unit
|
||||
suspend fun clearSelectedTeacher() = Unit
|
||||
|
||||
suspend fun autoUpdateEnabled(): Boolean
|
||||
suspend fun setAutoUpdateEnabled(enabled: Boolean)
|
||||
|
||||
suspend fun autoUpdateAsked(): Boolean
|
||||
suspend fun setAutoUpdateAsked(asked: Boolean)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package agzam.vyatsu.domain.repository
|
||||
|
||||
import agzam.vyatsu.domain.model.Department
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface TeacherRepository {
|
||||
fun observeDepartments(): Flow<List<Department>>
|
||||
suspend fun getDepartments(): List<Department>
|
||||
suspend fun getFaculties(): List<String>
|
||||
suspend fun refreshIfStale(): Boolean
|
||||
suspend fun forceRefresh(): Boolean
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package agzam.vyatsu.domain.repository
|
||||
|
||||
import agzam.vyatsu.domain.model.BulkSyncProgress
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.Teacher
|
||||
import agzam.vyatsu.domain.model.TeacherSchedule
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import java.time.LocalDate
|
||||
|
||||
interface TeacherScheduleRepository {
|
||||
fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>>
|
||||
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
|
||||
suspend fun syncPeriod(date: LocalDate, force: Boolean = false): Flow<BulkSyncProgress>
|
||||
suspend fun hasData(period: SchedulePeriod): Boolean
|
||||
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
|
||||
suspend fun clearAll()
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package agzam.vyatsu.ui.groups
|
||||
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Страница выбора группы
|
||||
* @author Agzam
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupPickerScreen(viewModel: GroupPickerViewModel, onGroupPicked: (Group) -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
if (!isSearchActive) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Выберите группу",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading && state.groups.isEmpty()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
if (state.error != null && state.groups.isEmpty()) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(state.error.orEmpty(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = { viewModel.retry() }) { Text("Повторить") }
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = state.searchQuery,
|
||||
onQueryChange = { viewModel.search(it) },
|
||||
onSearch = { isSearchActive = false },
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
placeholder = { Text("Поиск группы") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (isSearchActive && state.searchQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { viewModel.search("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
GroupList(
|
||||
groups = state.filteredGroups,
|
||||
onPick = { group ->
|
||||
isSearchActive = false
|
||||
onGroupPicked(group)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSearchActive) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
GroupList(state.filteredGroups, onGroupPicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(groups) { group ->
|
||||
Column(modifier = Modifier.fillMaxWidth() .clickable { onPick(group) }) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||
// XXX: стоит ли добавлять факультет или это засорит интерфейс
|
||||
// Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
|
||||
// if (group.faculty.isNotEmpty()) Text(text = group.faculty + " · ", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
// }
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package agzam.vyatsu.ui.groups
|
||||
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* @author Agzam
|
||||
*/
|
||||
class GroupPickerViewModel(private val repository: GroupRepository) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(GroupPickerUiState())
|
||||
val uiState: StateFlow<GroupPickerUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
private fun load() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
val ok = try {
|
||||
repository.refreshIfStale()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось загрузить группы"
|
||||
)
|
||||
false
|
||||
}
|
||||
if (!ok && _uiState.value.groups.isEmpty()) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = _uiState.value.error ?: "Не удалось обновить список групп"
|
||||
)
|
||||
}
|
||||
}
|
||||
repository.observeGroups().collect { groups ->
|
||||
val query = _uiState.value.searchQuery
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
groups = groups,
|
||||
filteredGroups = filterGroups(groups, query),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
val groups = _uiState.value.groups
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchQuery = query,
|
||||
filteredGroups = filterGroups(groups, query)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
|
||||
if (query.isBlank()) return groups
|
||||
val lowerQuery = query.lowercase()
|
||||
return groups.filter { group ->
|
||||
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
load()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
repository.forceRefresh()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
isLoading = false,
|
||||
error = e.message ?: "Не удалось обновить список групп"
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class GroupPickerUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val groups: List<Group> = emptyList(),
|
||||
val searchQuery: String = "",
|
||||
val filteredGroups: List<Group> = emptyList()
|
||||
)
|
||||
@@ -1,53 +1,85 @@
|
||||
package agzam.vyatsu.ui.home
|
||||
|
||||
import agzam.vyatsu.R
|
||||
import agzam.vyatsu.ui.info.BuildingsScreen
|
||||
import agzam.vyatsu.ui.info.CallsScreen
|
||||
import agzam.vyatsu.ui.schedule.ScheduleScreen
|
||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||
import agzam.vyatsu.ui.settings.SettingsScreen
|
||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||
import agzam.vyatsu.ui.teacher.TeacherScheduleScreen
|
||||
import agzam.vyatsu.ui.teacher.TeacherScheduleViewModel
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
private enum class HomeTab(val label: String) {
|
||||
SCHEDULE("Расписание"),
|
||||
CALLS("Звонки"),
|
||||
BUILDINGS("Корпуса")
|
||||
BUILDINGS("Корпуса"),
|
||||
SETTINGS("Настройки")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
scheduleViewModel: ScheduleViewModel,
|
||||
onSwitchGroup: () -> Unit
|
||||
teacherScheduleViewModel: TeacherScheduleViewModel,
|
||||
updateViewModel: UpdateViewModel,
|
||||
onSwitchGroup: () -> Unit,
|
||||
onSwitchTeacher: () -> Unit
|
||||
) {
|
||||
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
||||
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier,
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBar { // Новое нижнее меню
|
||||
HomeTab.entries.forEach { t ->
|
||||
NavigationBarItem(
|
||||
selected = tab == t,
|
||||
onClick = { tab = t },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = when (t) {
|
||||
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
||||
HomeTab.CALLS -> Icons.Default.Info
|
||||
HomeTab.BUILDINGS -> Icons.Default.Home
|
||||
painter = when (t) {
|
||||
HomeTab.SCHEDULE -> {
|
||||
if (tab == t) painterResource(R.drawable.calendar_month_24dp_fill)
|
||||
else painterResource(R.drawable.calendar_month_24dp_outline)
|
||||
}
|
||||
HomeTab.CALLS -> {
|
||||
if (tab == t) painterResource(R.drawable.notifications_active_24dp_fill)
|
||||
else painterResource(R.drawable.notifications_active_outline)
|
||||
}
|
||||
HomeTab.BUILDINGS -> {
|
||||
if (tab == t) painterResource(R.drawable.location_on_24dp_fill)
|
||||
else painterResource(R.drawable.location_on_24dp_outline)
|
||||
}
|
||||
HomeTab.SETTINGS -> {
|
||||
if (tab == t) painterResource(R.drawable.settings_24dp_fill)
|
||||
else painterResource(R.drawable.settings_24dp_outline)
|
||||
}
|
||||
},
|
||||
contentDescription = t.label
|
||||
)
|
||||
@@ -58,15 +90,23 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (tab) {
|
||||
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||
HomeTab.SCHEDULE -> if (teacherState.teacherName != null) {
|
||||
TeacherScheduleScreen(
|
||||
viewModel = teacherScheduleViewModel,
|
||||
onSwitchTeacher = onSwitchTeacher
|
||||
)
|
||||
} else {
|
||||
ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||
}
|
||||
HomeTab.CALLS -> CallsScreen()
|
||||
HomeTab.BUILDINGS -> BuildingsScreen()
|
||||
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,156 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import agzam.vyatsu.BuildConfig
|
||||
import agzam.vyatsu.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BuildingsScreen() { // Новый корпусов
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier,
|
||||
contentWindowInsets = WindowInsets(0.dp),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
title = {
|
||||
Text(
|
||||
text = "Корпуса", // Заголовок
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 12.dp,
|
||||
bottom = 24.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Расположение корпусов", // Еще заголовков
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Адреса учебных корпусов",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
items(BUILDINGS) { building ->
|
||||
// Карточки корпусов
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
onClick = {
|
||||
val uri = Uri.parse("geo:${building.latitude},${building.longitude}?q=${building.latitude},${building.longitude}(Корпус ${building.number})")
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceContainerLow
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.size(60.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "${building.number}",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Название
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Корпус ${building.number}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${building.address}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Время
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon (
|
||||
painter = painterResource(R.drawable.chevron_right_24dp),
|
||||
contentDescription = "Enter"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@Composable
|
||||
fun BuildingsScreen() {
|
||||
val context = LocalContext.current
|
||||
@@ -54,17 +203,7 @@ fun BuildingsScreen() {
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
@@ -1,21 +1,386 @@
|
||||
package agzam.vyatsu.ui.info
|
||||
|
||||
import agzam.vyatsu.data.static.CALLS
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CallsScreen() {
|
||||
|
||||
var now by remember {
|
||||
mutableStateOf(LocalTime.now())
|
||||
}
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) {
|
||||
now = LocalTime.now()
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
now = LocalTime.now()
|
||||
delay(15_000L)
|
||||
}
|
||||
}
|
||||
|
||||
val intervals = remember {
|
||||
val formatter = DateTimeFormatter.ofPattern("H:mm")
|
||||
|
||||
CALLS.mapIndexed { index, call ->
|
||||
CallInterval(
|
||||
number = index + 1,
|
||||
start = LocalTime.parse(call.start, formatter),
|
||||
end = LocalTime.parse(call.end, formatter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val current = intervals.firstOrNull {
|
||||
!now.isBefore(it.start) && now.isBefore(it.end)
|
||||
}
|
||||
|
||||
val next = intervals.firstOrNull {
|
||||
now.isBefore(it.start)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0.dp),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
title = {
|
||||
Text(
|
||||
text = "Звонки",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
vertical = 12.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
|
||||
item {
|
||||
CallsStatusCard(
|
||||
now = now,
|
||||
current = current,
|
||||
next = next,
|
||||
first = intervals.firstOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
items(
|
||||
items = intervals,
|
||||
key = { it.number }
|
||||
) { interval ->
|
||||
|
||||
val isCurrent =
|
||||
current?.number == interval.number
|
||||
|
||||
CallCard(
|
||||
interval = interval,
|
||||
now = now,
|
||||
isCurrent = isCurrent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class CallInterval(
|
||||
val number: Int,
|
||||
val start: LocalTime,
|
||||
val end: LocalTime
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun CallCard(
|
||||
interval: CallInterval,
|
||||
now: LocalTime,
|
||||
isCurrent: Boolean
|
||||
) {
|
||||
val isFinished = !now.isBefore(interval.end)
|
||||
|
||||
val status = when {
|
||||
isCurrent -> "Идёт сейчас"
|
||||
isFinished -> "Завершена"
|
||||
else -> "Впереди"
|
||||
}
|
||||
|
||||
val totalSeconds = ChronoUnit.SECONDS.between(
|
||||
interval.start,
|
||||
interval.end
|
||||
).coerceAtLeast(1)
|
||||
|
||||
val elapsedSeconds = ChronoUnit.SECONDS.between(
|
||||
interval.start,
|
||||
now
|
||||
)
|
||||
|
||||
val progress = (
|
||||
elapsedSeconds.toFloat() / totalSeconds
|
||||
).coerceIn(0f, 1f)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerLow
|
||||
}
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.size(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
},
|
||||
contentColor = if (isCurrent) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
}
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = interval.number.toString(),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isCurrent) {
|
||||
"Текущая пара"
|
||||
} else {
|
||||
"Пара"
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = interval.start.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = interval.end.toString(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isCurrent) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallsStatusCard(
|
||||
now: LocalTime,
|
||||
current: CallInterval?,
|
||||
next: CallInterval?,
|
||||
first: CallInterval?
|
||||
) {
|
||||
val title: String
|
||||
val subtitle: String
|
||||
val progress: Float
|
||||
|
||||
when {
|
||||
current != null -> {
|
||||
title = "Сейчас идёт ${current.number} пара"
|
||||
subtitle = "До конца ${minutesUntil(now, current.end)} мин"
|
||||
|
||||
val total = ChronoUnit.SECONDS.between(
|
||||
current.start,
|
||||
current.end
|
||||
).coerceAtLeast(1)
|
||||
|
||||
val elapsed = ChronoUnit.SECONDS.between(
|
||||
current.start,
|
||||
now
|
||||
)
|
||||
|
||||
progress = (
|
||||
elapsed.toFloat() / total
|
||||
).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
next != null -> {
|
||||
title = if (first?.number == next.number) {
|
||||
"Занятия ещё не начались"
|
||||
} else {
|
||||
"Сейчас перемена"
|
||||
}
|
||||
|
||||
subtitle = "До ${next.number} пары ${minutesUntil(now, next.start)} мин"
|
||||
progress = 0f
|
||||
}
|
||||
|
||||
else -> {
|
||||
title = "Занятия закончились"
|
||||
subtitle = "На сегодня пар больше нет"
|
||||
progress = 1f
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
if (current != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun minutesUntil(
|
||||
now: LocalTime,
|
||||
target: LocalTime
|
||||
): Long {
|
||||
val seconds = ChronoUnit.SECONDS.between(
|
||||
now,
|
||||
target
|
||||
).coerceAtLeast(0)
|
||||
|
||||
return (seconds + 59) / 60
|
||||
}
|
||||
|
||||
/*
|
||||
// Агзам
|
||||
@Composable
|
||||
fun CallsScreen() {
|
||||
LazyColumn(
|
||||
@@ -40,3 +405,5 @@ fun CallsScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,738 @@
|
||||
package agzam.vyatsu.ui.picker
|
||||
|
||||
import agzam.vyatsu.R
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Teacher
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SearchBar
|
||||
import androidx.compose.material3.SearchBarDefaults
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PickerScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PickerViewModel,
|
||||
onGroupPicked: (Group) -> Unit,
|
||||
onTeacherPicked: (Teacher) -> Unit,
|
||||
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var segment by rememberSaveable { mutableStateOf(PickerSegment.GROUPS) }
|
||||
|
||||
LaunchedEffect(segment)
|
||||
{
|
||||
viewModel.selectSegment(segment)
|
||||
}
|
||||
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val groupsListState = rememberLazyListState()
|
||||
val teachersListState = rememberLazyListState()
|
||||
|
||||
val listState = when (segment) {
|
||||
PickerSegment.GROUPS -> groupsListState
|
||||
PickerSegment.TEACHERS -> teachersListState
|
||||
}
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.isScrollInProgress }
|
||||
.collect { isScrolling ->
|
||||
if (isScrolling) {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val query = when (segment) {
|
||||
PickerSegment.GROUPS -> state.groupQuery
|
||||
PickerSegment.TEACHERS -> state.teacherQuery
|
||||
}
|
||||
|
||||
val onSearch: (String) -> Unit = when (segment) {
|
||||
PickerSegment.GROUPS -> viewModel::searchGroup
|
||||
PickerSegment.TEACHERS -> viewModel::searchTeacher
|
||||
}
|
||||
|
||||
val placeholder = when (segment) {
|
||||
PickerSegment.GROUPS -> "Поиск группы"
|
||||
PickerSegment.TEACHERS -> "Поиск преподавателя"
|
||||
}
|
||||
|
||||
|
||||
val isLoading = when (segment) {
|
||||
PickerSegment.GROUPS -> state.groupsLoading
|
||||
PickerSegment.TEACHERS -> state.teachersLoading
|
||||
}
|
||||
|
||||
val isEmpty = when (segment) {
|
||||
PickerSegment.GROUPS -> state.groups.isEmpty()
|
||||
PickerSegment.TEACHERS -> state.teachers.isEmpty()
|
||||
}
|
||||
|
||||
val onRetry: () -> Unit = when (segment) {
|
||||
PickerSegment.GROUPS -> viewModel::retryGroups
|
||||
PickerSegment.TEACHERS -> viewModel::retryTeachers
|
||||
}
|
||||
|
||||
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Кого ищем",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = {
|
||||
when (segment) {
|
||||
PickerSegment.GROUPS -> viewModel.refreshGroups()
|
||||
PickerSegment.TEACHERS -> viewModel.refreshTeachers()
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.refresh_24dp),
|
||||
contentDescription = "Обновить"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Выберите группу или преподавателя",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
SegmentedButton(
|
||||
selected = segment == PickerSegment.GROUPS,
|
||||
onClick = {
|
||||
segment = PickerSegment.GROUPS
|
||||
},
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = 0,
|
||||
count = 2
|
||||
),
|
||||
icon = {
|
||||
Icon(
|
||||
painter = if (segment == PickerSegment.GROUPS)
|
||||
painterResource(R.drawable.groups_24dp_fill)
|
||||
else painterResource(R.drawable.groups_24dp_outline),
|
||||
contentDescription = "Select group"
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Группы")
|
||||
}
|
||||
|
||||
SegmentedButton(
|
||||
selected = segment == PickerSegment.TEACHERS,
|
||||
onClick = {
|
||||
segment = PickerSegment.TEACHERS
|
||||
},
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = 1,
|
||||
count = 2
|
||||
),
|
||||
icon = {
|
||||
Icon(
|
||||
painter = if (segment == PickerSegment.TEACHERS)
|
||||
painterResource(R.drawable.person_24dp_fill)
|
||||
else painterResource(R.drawable.person_24dp_outline),
|
||||
contentDescription = "Select teacher"
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text("Преподаватели")
|
||||
}
|
||||
|
||||
}
|
||||
Spacer(Modifier.padding(8.dp))
|
||||
|
||||
PickerSearchBar(
|
||||
query = query,
|
||||
onQueryChange = onSearch,
|
||||
placeholder = placeholder
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (segment == PickerSegment.TEACHERS) {
|
||||
val progress = state.syncProgress
|
||||
|
||||
if (progress != null &&
|
||||
progress.total > 0 &&
|
||||
progress.done < progress.total
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = {
|
||||
progress.done.toFloat() /
|
||||
progress.total.toFloat()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Кафедры: ${progress.done}/${progress.total}",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && isEmpty) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else if (state.error != null && isEmpty) {
|
||||
Text(
|
||||
text = state.error ?: "",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
|
||||
TextButton(onClick = onRetry) {
|
||||
Text("Повторить")
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(bottom = 16.dp)
|
||||
) {
|
||||
when (segment) {
|
||||
PickerSegment.GROUPS -> {
|
||||
items(
|
||||
items = state.filteredGroups,
|
||||
key = { it.name }
|
||||
) { group ->
|
||||
PickerCard(
|
||||
name = group.name,
|
||||
segment = segment,
|
||||
onClick = {
|
||||
onGroupPicked(group)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PickerSegment.TEACHERS -> {
|
||||
items(
|
||||
items = state.filteredTeachers,
|
||||
key = { it.name }
|
||||
) { teacher ->
|
||||
PickerCard(
|
||||
name = teacher.name,
|
||||
segment = segment,
|
||||
onClick = {
|
||||
onTeacherPicked(teacher)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun PickerCard(
|
||||
modifier: Modifier = Modifier,
|
||||
name: String,
|
||||
segment: PickerSegment,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val iconRes = when (segment) {
|
||||
PickerSegment.GROUPS -> R.drawable.groups_24dp_outline
|
||||
PickerSegment.TEACHERS -> R.drawable.person_24dp_outline
|
||||
}
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 2.dp,
|
||||
pressedElevation = 4.dp
|
||||
),
|
||||
onClick = onClick
|
||||
) {
|
||||
Row (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = MaterialTheme.shapes.small
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = "Icons",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Text (
|
||||
text = name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.chevron_right_24dp),
|
||||
contentDescription = "Select",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PickerSearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
SearchBarDefaults.InputField(
|
||||
query = query,
|
||||
onQueryChange = onQueryChange,
|
||||
onSearch = {},
|
||||
expanded = false,
|
||||
onExpandedChange = {},
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(placeholder)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(
|
||||
onClick = { onQueryChange("") }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Clear,
|
||||
contentDescription = "Очистить поиск"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PickerScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: PickerViewModel,
|
||||
onGroupPicked: (Group) -> Unit,
|
||||
onTeacherPicked: (Teacher) -> Unit,
|
||||
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
var segment by rememberSaveable { mutableStateOf(PickerSegment.GROUPS) }
|
||||
LaunchedEffect(segment) { viewModel.selectSegment(segment) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = "Кого ищем",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
actions = {
|
||||
IconButton(onClick = { onRefresh() } ) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.refresh_24dp),
|
||||
contentDescription = "Обновить"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.systemBars)
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
|
||||
SegmentedButton(
|
||||
selected = segment == PickerSegment.GROUPS,
|
||||
onClick = { segment = PickerSegment.GROUPS },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
|
||||
) {
|
||||
Text("Группы")
|
||||
}
|
||||
SegmentedButton(
|
||||
selected = segment == PickerSegment.TEACHERS,
|
||||
onClick = { segment = PickerSegment.TEACHERS },
|
||||
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
|
||||
) {
|
||||
Text("Преподаватели")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
when (segment) {
|
||||
PickerSegment.GROUPS -> GroupSection(
|
||||
state = state,
|
||||
onSearch = viewModel::searchGroup,
|
||||
onRetry = viewModel::retryGroups,
|
||||
onRefresh = viewModel::refreshGroups,
|
||||
onGroupPicked = onGroupPicked
|
||||
)
|
||||
PickerSegment.TEACHERS -> TeacherSection(
|
||||
state = state,
|
||||
onSearch = viewModel::searchTeacher,
|
||||
onRetry = viewModel::retryTeachers,
|
||||
onTeacherPicked = onTeacherPicked
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun GroupSection(
|
||||
state: PickerUiState,
|
||||
onSearch: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onGroupPicked: (Group) -> Unit
|
||||
) {
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
|
||||
if (!isSearchActive) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Выберите группу",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.groupsLoading && state.groups.isEmpty()) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.error != null && state.groups.isEmpty()) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
state.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Повторить") }
|
||||
}
|
||||
return
|
||||
}
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = state.groupQuery,
|
||||
onQueryChange = onSearch,
|
||||
onSearch = { isSearchActive = false },
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
placeholder = { Text("Поиск группы") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (isSearchActive && state.groupQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { onSearch("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
GroupList(
|
||||
groups = state.filteredGroups,
|
||||
onPick = { group ->
|
||||
isSearchActive = false
|
||||
onGroupPicked(group)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSearchActive) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
GroupList(state.filteredGroups, onGroupPicked)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(groups) { group ->
|
||||
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(group) }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TeacherSection(
|
||||
state: PickerUiState,
|
||||
onSearch: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onTeacherPicked: (Teacher) -> Unit
|
||||
) {
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
val progress = state.syncProgress
|
||||
val syncing = progress != null && progress.done < progress.total
|
||||
|
||||
if (!isSearchActive) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Выберите преподавателя",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
IconButton(onClick = onRetry) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (syncing) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
LinearProgressIndicator(
|
||||
progress = { progress.done / progress.total.toFloat() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Кафедры: ${progress.done}/${progress.total}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.teachersLoading && state.teachers.isEmpty() && !syncing) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (state.error != null && state.teachers.isEmpty() && !syncing) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
state.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
TextButton(onClick = onRetry) { Text("Повторить") }
|
||||
}
|
||||
return
|
||||
}
|
||||
SearchBar(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
inputField = {
|
||||
SearchBarDefaults.InputField(
|
||||
query = state.teacherQuery,
|
||||
onQueryChange = onSearch,
|
||||
onSearch = { isSearchActive = false },
|
||||
expanded = isSearchActive,
|
||||
onExpandedChange = { isSearchActive = it },
|
||||
placeholder = { Text("Поиск преподавателя") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
trailingIcon = {
|
||||
if (isSearchActive && state.teacherQuery.isNotEmpty()) {
|
||||
IconButton(onClick = { onSearch("") }) {
|
||||
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
TeacherList(
|
||||
teachers = state.filteredTeachers,
|
||||
onPick = { teacher ->
|
||||
isSearchActive = false
|
||||
onTeacherPicked(teacher)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!isSearchActive) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
when {
|
||||
state.teachers.isEmpty() -> Text(
|
||||
text = "Кафедры без расписания на эту неделю",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
else -> TeacherList(state.filteredTeachers, onTeacherPicked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeacherList(teachers: List<Teacher>, onPick: (Teacher) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(teachers) { teacher ->
|
||||
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(teacher) }) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Text(teacher.name, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
@@ -0,0 +1,3 @@
|
||||
package agzam.vyatsu.ui.picker
|
||||
|
||||
enum class PickerSegment { GROUPS, TEACHERS }
|
||||
@@ -0,0 +1,229 @@
|
||||
package agzam.vyatsu.ui.picker
|
||||
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.BulkSyncProgress
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.Teacher
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.TeacherRepository
|
||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.time.DayOfWeek
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class PickerUiState(
|
||||
val segment: PickerSegment = PickerSegment.GROUPS,
|
||||
val groups: List<Group> = emptyList(),
|
||||
val groupQuery: String = "",
|
||||
val filteredGroups: List<Group> = emptyList(),
|
||||
val groupsLoading: Boolean = true,
|
||||
val teachers: List<Teacher> = emptyList(),
|
||||
val teacherQuery: String = "",
|
||||
val filteredTeachers: List<Teacher> = emptyList(),
|
||||
val teachersLoading: Boolean = true,
|
||||
val syncProgress: BulkSyncProgress? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
class PickerViewModel(
|
||||
private val groupRepository: GroupRepository,
|
||||
private val teacherRepository: TeacherRepository,
|
||||
private val teacherScheduleRepository: TeacherScheduleRepository,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(PickerUiState())
|
||||
val uiState: StateFlow<PickerUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var teachersJob: kotlinx.coroutines.Job? = null
|
||||
private var syncJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
init {
|
||||
groupsLoad()
|
||||
teachersLoad()
|
||||
}
|
||||
|
||||
fun selectSegment(segment: PickerSegment) {
|
||||
_uiState.value = _uiState.value.copy(segment = segment)
|
||||
}
|
||||
|
||||
private fun groupsLoad() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
groupRepository.refreshIfStale()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось загрузить группы"
|
||||
)
|
||||
}
|
||||
}
|
||||
groupRepository.observeGroups().collect { groups ->
|
||||
val query = _uiState.value.groupQuery
|
||||
_uiState.value = _uiState.value.copy(
|
||||
groupsLoading = false,
|
||||
groups = groups,
|
||||
filteredGroups = filterGroups(groups, query),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchGroup(query: String) {
|
||||
val groups = _uiState.value.groups
|
||||
_uiState.value = _uiState.value.copy(
|
||||
groupQuery = query,
|
||||
filteredGroups = filterGroups(groups, query)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
|
||||
if (query.isBlank()) return groups
|
||||
val lowerQuery = query.lowercase()
|
||||
return groups.filter { group ->
|
||||
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
|
||||
}
|
||||
}
|
||||
|
||||
fun retryGroups() {
|
||||
_uiState.value = _uiState.value.copy(groupsLoading = true, error = null)
|
||||
groupsLoad()
|
||||
}
|
||||
|
||||
fun refreshGroups() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
groupRepository.forceRefresh()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось обновить список групп"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun teachersLoad() {
|
||||
viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
teacherRepository.refreshIfStale()
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось загрузить кафедры"
|
||||
)
|
||||
}
|
||||
}
|
||||
syncAndObserveTeachers()
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncAndObserveTeachers() {
|
||||
val today = ScheduleDates.moscowToday()
|
||||
val weekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||
val period = SchedulePeriod(
|
||||
weekStart = weekStart,
|
||||
weekEnd = weekStart.plusDays(6),
|
||||
season = ScheduleDates.currentSeason(today)
|
||||
)
|
||||
|
||||
_uiState.value = _uiState.value.copy(syncProgress = null, teachersLoading = true)
|
||||
|
||||
syncJob?.cancel()
|
||||
syncJob = viewModelScope.launch {
|
||||
try {
|
||||
teacherScheduleRepository.syncPeriod(today).collect { progress ->
|
||||
_uiState.value = _uiState.value.copy(
|
||||
syncProgress = progress,
|
||||
error = if (progress.failed > 0) {
|
||||
"Не удалось загрузить расписание: ${progress.failed} из " +
|
||||
"${progress.total} кафедр. Проверьте подключение к интернету."
|
||||
} else {
|
||||
_uiState.value.error
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
error = e.message ?: "Не удалось загрузить расписание преподавателей"
|
||||
)
|
||||
} finally {
|
||||
_uiState.value = _uiState.value.copy(teachersLoading = false)
|
||||
}
|
||||
}
|
||||
|
||||
teachersJob?.cancel()
|
||||
teachersJob = viewModelScope.launch {
|
||||
teacherScheduleRepository.observeTeachers(period)
|
||||
.debounce(300)
|
||||
.collect { teachers ->
|
||||
val query = _uiState.value.teacherQuery
|
||||
val isLoading = _uiState.value.teachersLoading
|
||||
_uiState.value = _uiState.value.copy(
|
||||
teachers = teachers,
|
||||
filteredTeachers = filterTeachers(teachers, query),
|
||||
teachersLoading = isLoading && teachers.isEmpty(),
|
||||
error = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun searchTeacher(query: String) {
|
||||
val teachers = _uiState.value.teachers
|
||||
_uiState.value = _uiState.value.copy(
|
||||
teacherQuery = query,
|
||||
filteredTeachers = filterTeachers(teachers, query)
|
||||
)
|
||||
}
|
||||
|
||||
private fun filterTeachers(teachers: List<Teacher>, query: String): List<Teacher> {
|
||||
if (query.isBlank()) return teachers
|
||||
val lowerQuery = query.lowercase()
|
||||
return teachers.filter { teacher -> teacher.name.lowercase().contains(lowerQuery) }
|
||||
}
|
||||
|
||||
fun retryTeachers() {
|
||||
syncAndObserveTeachers()
|
||||
}
|
||||
|
||||
|
||||
// Добавил обновление списка преподов
|
||||
fun refreshTeachers() {
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
teachersLoading = true,
|
||||
error = null
|
||||
)
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
teacherRepository.forceRefresh()
|
||||
}
|
||||
syncAndObserveTeachers()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
teachersLoading = false,
|
||||
error = e.message ?: "Не удалось обновить преподавателей"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,54 +1,286 @@
|
||||
package agzam.vyatsu.ui.schedule
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.R
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import agzam.vyatsu.ui.schedule.components.DayChips
|
||||
import agzam.vyatsu.ui.schedule.components.FULL_DATE
|
||||
import agzam.vyatsu.ui.schedule.components.LessonSlot
|
||||
import agzam.vyatsu.ui.schedule.components.ScheduleDateHeader
|
||||
import agzam.vyatsu.ui.schedule.components.ScheduleEmptyDay
|
||||
import agzam.vyatsu.ui.schedule.components.ScheduleError
|
||||
import agzam.vyatsu.ui.schedule.components.WeekSelector
|
||||
import agzam.vyatsu.ui.schedule.components.dayName
|
||||
import agzam.vyatsu.ui.schedule.components.hPadding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScheduleScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ScheduleViewModel,
|
||||
onSwitchGroup: () -> Unit
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
contentWindowInsets = WindowInsets(0.dp),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = state.groupName ?: "Расписание",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Расписание занятий",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state.isLoading && state.schedule != null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.refresh_24dp),
|
||||
contentDescription = "Обновить"
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { onSwitchGroup() }) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.sync_alt_24dp),
|
||||
contentDescription = "Изменить выбор"
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when {
|
||||
state.schedule == null && state.loadError -> {
|
||||
ScheduleError(
|
||||
onRetry = viewModel::refresh
|
||||
)
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val schedule = state.schedule!!
|
||||
|
||||
val weekDays = state.weekStart
|
||||
?.let { weekStart ->
|
||||
schedule.periods.firstOrNull {
|
||||
it.weekStart == weekStart
|
||||
}
|
||||
}
|
||||
?.let { period ->
|
||||
schedule.daysOf(period)
|
||||
}
|
||||
.orEmpty()
|
||||
|
||||
if (weekDays.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Нет расписания на эту неделю")
|
||||
}
|
||||
} else {
|
||||
val selectedDayIndex = weekDays
|
||||
.indexOfFirst { it.date == state.dayDate }
|
||||
.coerceAtLeast(0)
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = selectedDayIndex,
|
||||
pageCount = { weekDays.size }
|
||||
)
|
||||
|
||||
LaunchedEffect(
|
||||
state.dayDate,
|
||||
weekDays
|
||||
) {
|
||||
val targetPage = weekDays.indexOfFirst {
|
||||
it.date == state.dayDate
|
||||
}
|
||||
|
||||
if (
|
||||
targetPage >= 0 &&
|
||||
targetPage != pagerState.currentPage
|
||||
) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState, weekDays) {
|
||||
snapshotFlow {
|
||||
pagerState.settledPage
|
||||
}.collect { page ->
|
||||
val selectedDate =
|
||||
weekDays.getOrNull(page)?.date
|
||||
|
||||
if (
|
||||
selectedDate != null &&
|
||||
selectedDate != state.dayDate
|
||||
) {
|
||||
viewModel.selectDay(selectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleDateHeader(
|
||||
date = state.dayDate,
|
||||
todayDate = state.todayDate,
|
||||
periods = schedule.periods,
|
||||
selectedWeekStart = state.weekStart,
|
||||
onSelectWeek = viewModel::selectWeek
|
||||
)
|
||||
|
||||
DayChips(
|
||||
days = weekDays,
|
||||
selectedDate = state.dayDate,
|
||||
todayDate = state.todayDate,
|
||||
pagerState = pagerState,
|
||||
onSelect = viewModel::selectDay
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f),
|
||||
key = { page ->
|
||||
weekDays[page].date.toEpochDay()
|
||||
}
|
||||
) { page ->
|
||||
|
||||
val day = weekDays.getOrNull(page)
|
||||
|
||||
if (day == null ||
|
||||
day.intervals.all { it.lessons.isEmpty() }
|
||||
) {
|
||||
ScheduleEmptyDay()
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(
|
||||
top = 16.dp,
|
||||
bottom = 24.dp
|
||||
)
|
||||
) {
|
||||
items(
|
||||
items = day.intervals.withIndex()
|
||||
.filter { (_, slot) ->
|
||||
slot.lessons.any { lesson ->
|
||||
!lesson.subject.isNullOrBlank() ||
|
||||
lesson.raw.isNotBlank()
|
||||
}
|
||||
},
|
||||
key = { it.index }
|
||||
) { (slotIndex, slot) ->
|
||||
LessonSlot(
|
||||
slotIndex = slotIndex,
|
||||
slot = slot
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text("Расписание") },
|
||||
title = { Text(state.groupName ?: "Расписание", style = MaterialTheme.typography.titleMedium) },
|
||||
actions = {
|
||||
if (state.isLoading && state.schedule != null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { onSwitchGroup() }) {
|
||||
Text("Сменить группу")
|
||||
}
|
||||
@@ -56,20 +288,11 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets.systemBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Top)
|
||||
windowInsets = WindowInsets(0.dp)
|
||||
)
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
when {
|
||||
state.isSyncing && state.schedule == null -> {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
state.schedule == null && state.loadError -> {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
@@ -84,6 +307,15 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val schedule = state.schedule!!
|
||||
val weekDays = state.weekStart
|
||||
@@ -95,53 +327,82 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
if (day == null) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
|
||||
val schedule = state.schedule!!
|
||||
val weekDays = state.weekStart
|
||||
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
|
||||
?.let { schedule.daysOf(it) }
|
||||
.orEmpty()
|
||||
|
||||
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = selectedDayIndex,
|
||||
pageCount = { weekDays.size }
|
||||
)
|
||||
LaunchedEffect(state.dayDate) {
|
||||
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
|
||||
if (targetPage != -1 && targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage) // Мгновенное переключение без анимации
|
||||
}
|
||||
}
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||
if (pagerState.isScrollInProgress && page in weekDays.indices) {
|
||||
viewModel.selectDay(weekDays[page].date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isTodayDay = day.date == state.todayDate
|
||||
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||
|
||||
Text(
|
||||
"$dayLabel ${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
Modifier.fillMaxWidth().padding(horizontal = hPadding),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextButton(onClick = { viewModel.clearSelectedPeriod() }) {
|
||||
Text("Очистить эту неделю")
|
||||
}
|
||||
TextButton(onClick = { viewModel.clearAll() }) {
|
||||
Text("Очистить всё")
|
||||
}
|
||||
Text(
|
||||
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
WeekSelector(
|
||||
periods = schedule.periods,
|
||||
selectedWeekStart = state.weekStart,
|
||||
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
|
||||
onSelect = viewModel::selectWeek
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
|
||||
WeekChips(
|
||||
periods = schedule.periods,
|
||||
selectedWeekStart = state.weekStart,
|
||||
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
|
||||
onSelect = viewModel::selectWeek
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
|
||||
|
||||
DayChips(
|
||||
days = weekDays,
|
||||
selectedDate = state.dayDate,
|
||||
todayDate = state.todayDate,
|
||||
onSelect = viewModel::selectDay
|
||||
onSelect = viewModel::selectDay,
|
||||
pagerState = pagerState
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
|
||||
|
||||
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
|
||||
val day = weekDays.getOrNull(page)
|
||||
|
||||
if (day == null || day.intervals.all { it.lessons.isEmpty() }) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,142 +412,4 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekChips(
|
||||
periods: List<SchedulePeriod>,
|
||||
selectedWeekStart: LocalDate?,
|
||||
todayWeekStart: LocalDate?,
|
||||
onSelect: (LocalDate) -> Unit
|
||||
) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(periods, key = { it.weekStart.toEpochDay() }) { period ->
|
||||
val selected = selectedWeekStart == period.weekStart
|
||||
val isTodayWeek = todayWeekStart == period.weekStart
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onSelect(period.weekStart) },
|
||||
label = {
|
||||
Text(
|
||||
weekLabel(period),
|
||||
style = MaterialTheme.typography.labelLarge
|
||||
)
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
border = if (isTodayWeek && !selected) {
|
||||
FilterChipDefaults.filterChipBorder(
|
||||
borderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 1.dp,
|
||||
enabled = true,
|
||||
selected = false
|
||||
)
|
||||
} else {
|
||||
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayChips(
|
||||
days: List<Day>,
|
||||
selectedDate: LocalDate?,
|
||||
todayDate: LocalDate?,
|
||||
onSelect: (LocalDate) -> Unit
|
||||
) {
|
||||
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(days, key = { it.date.toEpochDay() }) { day ->
|
||||
val selected = selectedDate == day.date
|
||||
val isTodayChip = todayDate == day.date
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = { onSelect(day.date) },
|
||||
label = {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
dayName(day.date.dayOfWeek),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
Text(
|
||||
day.date.format(CHIP_DATE),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
border = if (isTodayChip && !selected) {
|
||||
FilterChipDefaults.filterChipBorder(
|
||||
borderColor = MaterialTheme.colorScheme.primary,
|
||||
borderWidth = 1.dp,
|
||||
enabled = true,
|
||||
selected = false
|
||||
)
|
||||
} else {
|
||||
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun weekLabel(period: SchedulePeriod): String {
|
||||
val first = period.weekStart
|
||||
return "с ${first.format(CHIP_DATE)}"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (slot.lessons.isEmpty()) {
|
||||
Text("—", style = MaterialTheme.typography.bodyMedium)
|
||||
} else {
|
||||
slot.lessons.forEach { lesson ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
|
||||
Text(
|
||||
lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
listOfNotNull(
|
||||
lesson.type.label,
|
||||
lesson.subgroup,
|
||||
lesson.teacher,
|
||||
lesson.room
|
||||
)
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
||||
DayOfWeek.MONDAY -> "Пн"
|
||||
DayOfWeek.TUESDAY -> "Вт"
|
||||
DayOfWeek.WEDNESDAY -> "Ср"
|
||||
DayOfWeek.THURSDAY -> "Чт"
|
||||
DayOfWeek.FRIDAY -> "Пт"
|
||||
DayOfWeek.SATURDAY -> "Сб"
|
||||
DayOfWeek.SUNDAY -> "Вс"
|
||||
}
|
||||
|
||||
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
*/
|
||||
@@ -2,6 +2,7 @@ package agzam.vyatsu.ui.schedule
|
||||
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.Schedule
|
||||
import agzam.vyatsu.domain.repository.GroupRepository
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
@@ -14,7 +15,8 @@ import java.time.LocalDate
|
||||
|
||||
class ScheduleViewModel(
|
||||
private val scheduleRepository: ScheduleRepository,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val groupRepository: GroupRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ScheduleUiState())
|
||||
@@ -28,15 +30,17 @@ class ScheduleViewModel(
|
||||
if (groupId != null) {
|
||||
selectGroup(groupId)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
_uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectGroup(groupId: String) {
|
||||
currentGroupId = groupId
|
||||
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setSelectedGroup(groupId)
|
||||
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||
|
||||
val savedWeekStart = runCatching {
|
||||
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
|
||||
@@ -46,6 +50,7 @@ class ScheduleViewModel(
|
||||
}.getOrNull()
|
||||
_uiState.value = _uiState.value.copy(
|
||||
noGroup = false,
|
||||
groupName = groupName,
|
||||
weekStart = savedWeekStart,
|
||||
dayDate = savedDayDate
|
||||
)
|
||||
@@ -57,7 +62,7 @@ class ScheduleViewModel(
|
||||
}
|
||||
|
||||
launch {
|
||||
syncIfMissingToday(groupId)
|
||||
syncIfMissing(groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +97,9 @@ class ScheduleViewModel(
|
||||
schedule = schedule,
|
||||
weekStart = weekStart,
|
||||
dayDate = dayDate,
|
||||
todayDate = todayDate
|
||||
todayDate = todayDate,
|
||||
isLoading = false,
|
||||
loadError = false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -124,30 +131,39 @@ class ScheduleViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncIfMissingToday(groupId: String) {
|
||||
val schedule = scheduleRepository.getSchedule(groupId)
|
||||
val today = ScheduleDates.moscowToday()
|
||||
val hasTodayPeriod = schedule?.periodOf(today) != null
|
||||
if (hasTodayPeriod) return
|
||||
_uiState.value = _uiState.value.copy(isSyncing = true)
|
||||
scheduleRepository.sync(groupId)
|
||||
_uiState.value = _uiState.value.copy(isSyncing = false)
|
||||
private suspend fun syncIfMissing(groupId: String) {
|
||||
if (scheduleRepository.getSchedule(groupId) != null) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false)
|
||||
return
|
||||
}
|
||||
loadSchedule(groupId)
|
||||
}
|
||||
|
||||
private suspend fun loadSchedule(groupId: String) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||
val schedule = scheduleRepository.sync(groupId)
|
||||
_uiState.value = if (schedule != null) {
|
||||
_uiState.value.copy(isLoading = false, loadError = false)
|
||||
} else {
|
||||
_uiState.value.copy(isLoading = false, loadError = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun switchGroup() {
|
||||
viewModelScope.launch {
|
||||
currentGroupId = null
|
||||
preferencesRepository.clearSelectedGroup()
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
_uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val groupId = currentGroupId ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
||||
scheduleRepository.sync(groupId)
|
||||
_uiState.value = _uiState.value.copy(isRefreshing = false)
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
|
||||
runCatching { groupRepository.forceRefresh() }
|
||||
loadSchedule(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,9 +187,10 @@ class ScheduleViewModel(
|
||||
|
||||
data class ScheduleUiState(
|
||||
val noGroup: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isSyncing: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val loadError: Boolean = false,
|
||||
val schedule: Schedule? = null,
|
||||
val groupName: String? = null,
|
||||
val weekStart: LocalDate? = null,
|
||||
val dayDate: LocalDate? = null,
|
||||
val todayDate: LocalDate? = null
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package agzam.vyatsu.ui.schedule.components
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import java.time.LocalDate
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
internal 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
|
||||
internal fun DayChips(
|
||||
days: List<Day>,
|
||||
selectedDate: LocalDate?,
|
||||
todayDate: LocalDate?,
|
||||
pagerState: PagerState,
|
||||
onSelect: (LocalDate) -> Unit
|
||||
) {
|
||||
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(8.dp),
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = hPadding,
|
||||
vertical = 8.dp
|
||||
)
|
||||
) {
|
||||
items(
|
||||
items = days,
|
||||
key = { it.date.toEpochDay() }
|
||||
) { day ->
|
||||
|
||||
val selected = selectedDate == day.date
|
||||
val isToday = todayDate == day.date
|
||||
|
||||
Surface(
|
||||
onClick = { onSelect(day.date) },
|
||||
modifier = Modifier.size(
|
||||
width = 72.dp,
|
||||
height = 72.dp
|
||||
),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerLow
|
||||
},
|
||||
contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
border = if (isToday && !selected) {
|
||||
BorderStroke(
|
||||
width = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = dayName(day.date.dayOfWeek),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = day.date.format(CHIP_DATE),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package agzam.vyatsu.ui.schedule.components
|
||||
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
internal fun LessonSlot(
|
||||
slotIndex: Int,
|
||||
slot: TimeSlot,
|
||||
showTeacher: Boolean = true
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = hPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${slotIndex + 1} пара",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
slot.lessons.forEach { lesson ->
|
||||
|
||||
val subject = lesson.subject
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: lesson.raw
|
||||
|
||||
val details = listOfNotNull(
|
||||
lesson.type.takeIf { it != LessonType.OTHER }?.label,
|
||||
lesson.subgroup,
|
||||
if (showTeacher) lesson.teacher else lesson.group,
|
||||
lesson.room
|
||||
).filter { it.isNotBlank() }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor =
|
||||
MaterialTheme.colorScheme.surfaceContainerLow
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = subject,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
if (details.isNotEmpty()) {
|
||||
Text(
|
||||
text = details.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
lesson.department
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { department ->
|
||||
HorizontalDivider()
|
||||
|
||||
Text(
|
||||
text = department,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
@Composable
|
||||
internal fun LessonSlot(slotIndex: Int, slot: TimeSlot, showTeacher: Boolean = true) {
|
||||
val context = LocalContext.current
|
||||
var indexColor = 0
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.width(10.dp + hPadding).padding(start = hPadding),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 4.dp)
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f).padding(end = hPadding))
|
||||
}
|
||||
if (slot.lessons.isEmpty()) {
|
||||
Text("—", style = MaterialTheme.typography.bodyMedium)
|
||||
} else {
|
||||
slot.lessons.forEachIndexed { index, lesson ->
|
||||
var baseSurface = Modifier.fillMaxWidth().padding(vertical = 2.dp)
|
||||
if ((indexColor) % 2 != 0) {
|
||||
baseSurface = baseSurface.background(
|
||||
MaterialTheme.colorScheme.surfaceContainer
|
||||
);
|
||||
}
|
||||
indexColor++
|
||||
// XXX:
|
||||
// Убрать SelectionContainer?
|
||||
// Режим редактирования расписания?
|
||||
// Меню на onClick?
|
||||
// Меню на onLongClick?
|
||||
SelectionContainer() {
|
||||
Column(
|
||||
baseSurface
|
||||
// XXX: combinedClickable?
|
||||
//.combinedClickable(
|
||||
// onClick = {
|
||||
// Toast.makeText(
|
||||
// context,
|
||||
// lesson.raw,
|
||||
// Toast.LENGTH_SHORT
|
||||
// ).show()
|
||||
// },
|
||||
// onLongClick = {},
|
||||
// // TODO: onLongClickLabel = "(Accessibility)"
|
||||
//),
|
||||
) {
|
||||
var any = false
|
||||
|
||||
if (lesson.department != null) {
|
||||
Text(
|
||||
lesson.department,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = hPadding)
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
val meta = listOfNotNull(
|
||||
if (lesson.type == LessonType.OTHER) {
|
||||
null
|
||||
} else {
|
||||
lesson.type.label
|
||||
},
|
||||
lesson.subgroup,
|
||||
if (showTeacher) lesson.teacher else lesson.group,
|
||||
lesson.room
|
||||
).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
|
||||
if (meta.isNotEmpty()) {
|
||||
Text(
|
||||
meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = hPadding)
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
||||
if (subject.isNotEmpty()) {
|
||||
Text(
|
||||
subject,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(horizontal = hPadding)
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
if (!any) {
|
||||
Text(
|
||||
"пусто",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = hPadding)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,68 @@
|
||||
package agzam.vyatsu.ui.schedule.components
|
||||
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.LocalDate
|
||||
|
||||
@Composable
|
||||
internal fun ScheduleDateHeader(
|
||||
date: LocalDate?,
|
||||
todayDate: LocalDate?,
|
||||
periods: List<SchedulePeriod>,
|
||||
selectedWeekStart: LocalDate?,
|
||||
onSelectWeek: (LocalDate) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = hPadding,
|
||||
vertical = 12.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = if (date == todayDate) {
|
||||
"Сегодня"
|
||||
} else {
|
||||
"Выбранный день"
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Text(
|
||||
text = date?.let {
|
||||
"${dayName(it.dayOfWeek)} · ${it.format(FULL_DATE)}"
|
||||
} ?: "Дата не выбрана",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
|
||||
// WeekSelector(
|
||||
// periods = periods,
|
||||
// selectedWeekStart = selectedWeekStart,
|
||||
// todayWeekStart = todayDate?.let { today ->
|
||||
// periods.firstOrNull { period ->
|
||||
// !today.isBefore(period.weekStart) &&
|
||||
// !today.isAfter(period.weekEnd)
|
||||
// }?.weekStart
|
||||
// },
|
||||
// onSelect = onSelectWeek
|
||||
// )
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package agzam.vyatsu.ui.schedule.components
|
||||
|
||||
import agzam.vyatsu.R
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
internal fun ScheduleEmptyDay() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.event_busy_24dp),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Занятий нет",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "На этот день расписание пустое",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ScheduleError(
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Не удалось загрузить расписание",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Button(onClick = onRetry) {
|
||||
Text("Повторить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package agzam.vyatsu.ui.schedule.components
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
internal val hPadding = 16.dp
|
||||
|
||||
internal fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
||||
DayOfWeek.MONDAY -> "Пн"
|
||||
DayOfWeek.TUESDAY -> "Вт"
|
||||
DayOfWeek.WEDNESDAY -> "Ср"
|
||||
DayOfWeek.THURSDAY -> "Чт"
|
||||
DayOfWeek.FRIDAY -> "Пт"
|
||||
DayOfWeek.SATURDAY -> "Сб"
|
||||
DayOfWeek.SUNDAY -> "Вс"
|
||||
}
|
||||
|
||||
internal val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||
internal val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||
internal val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||
@@ -0,0 +1,96 @@
|
||||
package agzam.vyatsu.ui.schedule.components
|
||||
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.time.LocalDate
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun WeekSelector(
|
||||
periods: List<SchedulePeriod>,
|
||||
selectedWeekStart: LocalDate?,
|
||||
todayWeekStart: LocalDate?,
|
||||
onSelect: (LocalDate) -> Unit
|
||||
) {
|
||||
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable(enabled = true, onClick = { expanded = true })
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
selected?.let(::weekLabel) ?: "Период",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.widthIn(min = 220.dp)
|
||||
) {
|
||||
periods.forEach { period ->
|
||||
val isTodayWeek = todayWeekStart == period.weekStart
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
weekLabel(period),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
trailingIcon = if (isTodayWeek) {
|
||||
{
|
||||
Text(
|
||||
"сегодня",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onSelect(period.weekStart)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun weekLabel(period: SchedulePeriod): String {
|
||||
val first = period.weekStart
|
||||
return "с ${first.format(CHIP_DATE)}"
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package agzam.vyatsu.ui.settings
|
||||
|
||||
import agzam.vyatsu.BuildConfig
|
||||
import agzam.vyatsu.R
|
||||
import agzam.vyatsu.update.UpdateState
|
||||
import android.widget.HorizontalScrollView
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: UpdateViewModel) {
|
||||
|
||||
val autoEnabled by viewModel.autoEnabled.collectAsState()
|
||||
val updateState by viewModel.updateState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier,
|
||||
contentWindowInsets = WindowInsets(0.dp),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
title = {
|
||||
Text(
|
||||
text = "Настройки",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
|
||||
SectionTitle("Обновления")
|
||||
}
|
||||
|
||||
item {
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = painterResource(R.drawable.cached_24dp),
|
||||
title = "Автоматические обновления",
|
||||
subtitle = "Проверять новые версии при запуске",
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = autoEnabled,
|
||||
onCheckedChange = viewModel::setAutoEnabled
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.setAutoEnabled(!autoEnabled)
|
||||
}
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 72.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
SettingsRow(
|
||||
icon = painterResource(R.drawable.download_24dp),
|
||||
title = "Проверить обновления",
|
||||
subtitle = updateStatusText(
|
||||
updateState,
|
||||
autoEnabled
|
||||
),
|
||||
trailing = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.chevron_right_24dp),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
},
|
||||
onClick = viewModel::checkNow
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
autoEnabled &&
|
||||
(updateState is UpdateState.Available ||
|
||||
updateState is UpdateState.ReadyToInstall)
|
||||
) {
|
||||
item {
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = painterResource(R.drawable.download_done_24dp),
|
||||
title = "Установить обновление",
|
||||
subtitle = installHint(
|
||||
updateState,
|
||||
viewModel
|
||||
),
|
||||
onClick = {
|
||||
when (updateState) {
|
||||
is UpdateState.Available ->
|
||||
viewModel.downloadAndInstall()
|
||||
|
||||
is UpdateState.ReadyToInstall ->
|
||||
viewModel.installDownloadedApk()
|
||||
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
SectionTitle("О приложении")
|
||||
}
|
||||
|
||||
item {
|
||||
SettingsGroup {
|
||||
SettingsRow(
|
||||
icon = painterResource(R.drawable.info_24dp),
|
||||
title = "Версия приложения",
|
||||
subtitle = "Текущая установленная версия",
|
||||
trailing = {
|
||||
Text(
|
||||
text = BuildConfig.VERSION_NAME,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(
|
||||
title: String
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SettingsGroup(
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
) {
|
||||
Column(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
icon: Painter,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (onClick != null) {
|
||||
Modifier.clickable(onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.padding(16.dp),
|
||||
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.size(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun updateStatusText(state: UpdateState, autoEnabled: Boolean): String = when (state) {
|
||||
is UpdateState.Idle -> "Проверить сейчас"
|
||||
is UpdateState.Checking -> "Проверка…"
|
||||
is UpdateState.UpToDate -> "Установлена последняя версия"
|
||||
is UpdateState.Available ->
|
||||
if (autoEnabled) "Доступна версия ${state.version}"
|
||||
else "Доступна версия ${state.version} — включите автообновления для установки"
|
||||
is UpdateState.Downloading -> "Скачивание: ${state.progress?.let { "$it%" } ?: "…"}"
|
||||
is UpdateState.ReadyToInstall -> "Обновление скачано, нажмите для установки"
|
||||
is UpdateState.Failed -> "Ошибка: ${state.message}"
|
||||
}
|
||||
|
||||
private fun installHint(state: UpdateState, viewModel: UpdateViewModel): String = when {
|
||||
state is UpdateState.ReadyToInstall && !viewModel.canRequestInstalls() ->
|
||||
"Разрешите установку приложений из этого источника"
|
||||
else -> "Нажмите, чтобы продолжить"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: UpdateViewModel) {
|
||||
val autoEnabled by viewModel.autoEnabled.collectAsState()
|
||||
val updateState by viewModel.updateState.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
) {
|
||||
item { SectionHeader("Обновления") }
|
||||
item {
|
||||
SettingsItem(
|
||||
title = "Автоматические обновления",
|
||||
subtitle = "Проверять и устанавливать новые версии при запуске",
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = autoEnabled,
|
||||
onCheckedChange = { viewModel.setAutoEnabled(it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
item {
|
||||
SettingsItem(
|
||||
title = "Проверить наличие обновлений",
|
||||
subtitle = updateStatusText(updateState, autoEnabled),
|
||||
onClick = { viewModel.checkNow() }
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
if (autoEnabled && (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall)) {
|
||||
item {
|
||||
SettingsItem(
|
||||
title = "Установить обновление",
|
||||
subtitle = installHint(updateState, viewModel),
|
||||
onClick = {
|
||||
when (updateState) {
|
||||
is UpdateState.Available -> viewModel.downloadAndInstall()
|
||||
is UpdateState.ReadyToInstall -> viewModel.installDownloadedApk()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
}
|
||||
|
||||
item { SectionHeader("О приложении") }
|
||||
item {
|
||||
SettingsItem(
|
||||
title = "Версия",
|
||||
trailing = {
|
||||
Text(
|
||||
BuildConfig.VERSION_NAME,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
|
||||
/*
|
||||
@Composable
|
||||
private fun SettingsItem(
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
trailing: (@Composable () -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsDivider() {
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,58 @@
|
||||
package agzam.vyatsu.ui.settings
|
||||
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.update.AppUpdater
|
||||
import agzam.vyatsu.update.UpdateState
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class UpdateViewModel(
|
||||
private val updater: AppUpdater,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val updateState: StateFlow<UpdateState> = updater.state
|
||||
|
||||
private val _autoEnabled = MutableStateFlow(false)
|
||||
val autoEnabled: StateFlow<Boolean> = _autoEnabled.asStateFlow()
|
||||
|
||||
private val _showFirstRunDialog = MutableStateFlow(false)
|
||||
val showFirstRunDialog: StateFlow<Boolean> = _showFirstRunDialog.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_autoEnabled.value = preferencesRepository.autoUpdateEnabled()
|
||||
_showFirstRunDialog.value = !preferencesRepository.autoUpdateAsked()
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoEnabled(enabled: Boolean) {
|
||||
_autoEnabled.value = enabled
|
||||
updater.setAutoEnabled(enabled)
|
||||
if (enabled && !updater.canRequestInstalls()) {
|
||||
updater.openUnknownSourcesSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun onFirstRunDecision(enable: Boolean) {
|
||||
_showFirstRunDialog.value = false
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setAutoUpdateAsked(true)
|
||||
}
|
||||
setAutoEnabled(enable)
|
||||
}
|
||||
|
||||
fun checkNow() = updater.checkForUpdate()
|
||||
|
||||
fun downloadAndInstall() = updater.downloadAndInstall()
|
||||
|
||||
fun installDownloadedApk() = updater.installDownloadedApk()
|
||||
|
||||
fun canRequestInstalls() = updater.canRequestInstalls()
|
||||
|
||||
fun openUnknownSourcesSettings() = updater.openUnknownSourcesSettings()
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
package agzam.vyatsu.ui.teacher
|
||||
|
||||
import agzam.vyatsu.R
|
||||
import agzam.vyatsu.ui.schedule.components.DayChips
|
||||
import agzam.vyatsu.ui.schedule.components.FULL_DATE
|
||||
import agzam.vyatsu.ui.schedule.components.LessonSlot
|
||||
import agzam.vyatsu.ui.schedule.components.ScheduleDateHeader
|
||||
import agzam.vyatsu.ui.schedule.components.ScheduleError
|
||||
import agzam.vyatsu.ui.schedule.components.WeekSelector
|
||||
import agzam.vyatsu.ui.schedule.components.dayName
|
||||
import agzam.vyatsu.ui.schedule.components.hPadding
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TeacherScheduleScreen(
|
||||
viewModel: TeacherScheduleViewModel,
|
||||
onSwitchTeacher: () -> Unit
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0.dp),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
windowInsets = WindowInsets(0.dp),
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = state.teacherName ?: "Расписание",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Расписание преподавателя",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state.isLoading && state.schedule != null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.refresh() }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.refresh_24dp),
|
||||
contentDescription = "Обновить"
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onSwitchTeacher
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.sync_alt_24dp),
|
||||
contentDescription = "Сменить преподавателя"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when {
|
||||
state.schedule == null && state.loadError -> {
|
||||
ScheduleError(
|
||||
onRetry = viewModel::refresh
|
||||
)
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val schedule = state.schedule!!
|
||||
|
||||
val weekDays = schedule.days
|
||||
|
||||
val periods = state.weeks.ifEmpty {
|
||||
listOf(schedule.period)
|
||||
}
|
||||
|
||||
if (weekDays.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Нет расписания на эту неделю")
|
||||
}
|
||||
} else {
|
||||
val selectedDayIndex = weekDays
|
||||
.indexOfFirst { it.date == state.dayDate }
|
||||
.coerceAtLeast(0)
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = selectedDayIndex,
|
||||
pageCount = { weekDays.size }
|
||||
)
|
||||
|
||||
// Синхронизация выбора дня с Pager
|
||||
LaunchedEffect(
|
||||
state.dayDate,
|
||||
weekDays
|
||||
) {
|
||||
val targetPage = weekDays.indexOfFirst {
|
||||
it.date == state.dayDate
|
||||
}
|
||||
|
||||
if (
|
||||
targetPage >= 0 &&
|
||||
targetPage != pagerState.currentPage
|
||||
) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
|
||||
// Переключение дня свайпом
|
||||
LaunchedEffect(pagerState, weekDays) {
|
||||
snapshotFlow {
|
||||
pagerState.settledPage
|
||||
}.collect { page ->
|
||||
|
||||
val selectedDate =
|
||||
weekDays.getOrNull(page)?.date
|
||||
|
||||
if (
|
||||
selectedDate != null &&
|
||||
selectedDate != state.dayDate
|
||||
) {
|
||||
viewModel.selectDay(selectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleDateHeader(
|
||||
date = state.dayDate,
|
||||
todayDate = state.todayDate,
|
||||
periods = periods,
|
||||
selectedWeekStart = state.weekStart,
|
||||
onSelectWeek = viewModel::selectWeek
|
||||
)
|
||||
|
||||
DayChips(
|
||||
days = weekDays,
|
||||
selectedDate = state.dayDate,
|
||||
todayDate = state.todayDate,
|
||||
pagerState = pagerState,
|
||||
onSelect = viewModel::selectDay
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.weight(1f),
|
||||
key = { page ->
|
||||
weekDays[page].date.toEpochDay()
|
||||
}
|
||||
) { page ->
|
||||
|
||||
val day = weekDays.getOrNull(page)
|
||||
|
||||
val visibleSlots = day?.intervals
|
||||
?.withIndex()
|
||||
?.filter { (_, slot) ->
|
||||
slot.lessons.any { lesson ->
|
||||
!lesson.subject.isNullOrBlank() ||
|
||||
lesson.raw.isNotBlank()
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
|
||||
if (visibleSlots.isEmpty()) {
|
||||
ScheduleEmptyDay()
|
||||
} else {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement =
|
||||
Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(
|
||||
top = 16.dp,
|
||||
bottom = 24.dp
|
||||
)
|
||||
) {
|
||||
items(
|
||||
items = visibleSlots,
|
||||
key = { it.index }
|
||||
) { (slotIndex, slot) ->
|
||||
LessonSlot(
|
||||
slotIndex = slotIndex,
|
||||
slot = slot,
|
||||
showTeacher = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScheduleEmptyDay() {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
/*
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher: () -> Unit) {
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
TopAppBar(
|
||||
title = { Text(state.teacherName ?: "Расписание преподавателя", style = MaterialTheme.typography.titleMedium) },
|
||||
actions = {
|
||||
if (state.isLoading && state.schedule != null) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { onSwitchTeacher() }) {
|
||||
Text("Сменить преподавателя")
|
||||
}
|
||||
IconButton(onClick = { viewModel.refresh() }) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||
}
|
||||
},
|
||||
windowInsets = WindowInsets(0.dp)
|
||||
)
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
when {
|
||||
state.schedule == null && state.loadError -> {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
"Не удалось загрузить расписание",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
TextButton(onClick = { viewModel.refresh() }) {
|
||||
Text("Повторить")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val schedule = state.schedule!!
|
||||
val weekDays = schedule.days
|
||||
val day = weekDays.firstOrNull { it.date == state.dayDate }
|
||||
|
||||
if (day == null) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
} else {
|
||||
|
||||
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = selectedDayIndex,
|
||||
pageCount = { weekDays.size }
|
||||
)
|
||||
LaunchedEffect(state.dayDate) {
|
||||
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
|
||||
if (targetPage != -1 && targetPage != pagerState.currentPage) {
|
||||
pagerState.scrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
||||
if (pagerState.isScrollInProgress && page in weekDays.indices) {
|
||||
viewModel.selectDay(weekDays[page].date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isTodayDay = day.date == state.todayDate
|
||||
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = hPadding),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
WeekSelector(
|
||||
periods = state.weeks.ifEmpty { listOf(schedule.period) },
|
||||
selectedWeekStart = state.weekStart,
|
||||
todayWeekStart = state.todayDate
|
||||
?.let { todayDate ->
|
||||
state.weeks.firstOrNull { period ->
|
||||
!todayDate.isBefore(period.weekStart) && !todayDate.isAfter(period.weekEnd)
|
||||
}?.weekStart
|
||||
}
|
||||
?: schedule.period.weekStart.takeIf {
|
||||
state.todayDate?.let { today -> !today.isBefore(it) && !today.isAfter(it.plusDays(6)) } == true
|
||||
},
|
||||
onSelect = viewModel::selectWeek
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
|
||||
|
||||
DayChips(
|
||||
days = weekDays,
|
||||
selectedDate = state.dayDate,
|
||||
todayDate = state.todayDate,
|
||||
onSelect = viewModel::selectDay,
|
||||
pagerState = pagerState
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(top = 0.dp, start = hPadding, end = hPadding))
|
||||
|
||||
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
|
||||
val pagerDay = weekDays.getOrNull(page)
|
||||
|
||||
if (pagerDay == null || pagerDay.intervals.all { it.lessons.isEmpty() }) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
itemsIndexed(pagerDay.intervals) { slotIndex, slot ->
|
||||
LessonSlot(slotIndex = slotIndex, slot = slot, showTeacher = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -0,0 +1,243 @@
|
||||
package agzam.vyatsu.ui.teacher
|
||||
|
||||
import agzam.vyatsu.domain.ScheduleDates
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.TeacherSchedule
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TeacherScheduleViewModel(
|
||||
private val scheduleRepository: TeacherScheduleRepository,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(TeacherScheduleUiState())
|
||||
val uiState: StateFlow<TeacherScheduleUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var currentTeacherName: String? = null
|
||||
private var currentPeriod: SchedulePeriod? = null
|
||||
private var scheduleJob: Job? = null
|
||||
private var weeksJob: Job? = null
|
||||
private var loadRevision = 0
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val teacherId = preferencesRepository.selectedTeacherId()
|
||||
if (teacherId != null) {
|
||||
selectTeacher(teacherId)
|
||||
} else {
|
||||
_uiState.value = _uiState.value.copy(noTeacher = true, teacherName = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectTeacher(name: String) {
|
||||
val revision = ++loadRevision
|
||||
currentTeacherName = name
|
||||
_uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false)
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
preferencesRepository.setSelectedTeacherId(name)
|
||||
|
||||
val savedWeekStart = runCatching {
|
||||
preferencesRepository.selectedTeacherWeekStart()?.let(LocalDate::parse)
|
||||
}.getOrNull()
|
||||
val savedDayDate = runCatching {
|
||||
preferencesRepository.selectedTeacherDayDate()?.let(LocalDate::parse)
|
||||
}.getOrNull()
|
||||
|
||||
val today = ScheduleDates.moscowToday()
|
||||
val weekStart = savedWeekStart
|
||||
?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||
if (revision != loadRevision) return@launch
|
||||
_uiState.value = _uiState.value.copy(
|
||||
noTeacher = false,
|
||||
teacherName = name,
|
||||
weekStart = weekStart,
|
||||
dayDate = savedDayDate
|
||||
)
|
||||
|
||||
val period = SchedulePeriod(
|
||||
weekStart = weekStart,
|
||||
weekEnd = weekStart.plusDays(6),
|
||||
season = ScheduleDates.currentSeason(today)
|
||||
)
|
||||
loadSchedule(period, revision)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadSchedule(period: SchedulePeriod, revision: Int) {
|
||||
if (revision != loadRevision) return
|
||||
scheduleJob?.cancel()
|
||||
scheduleJob = null
|
||||
currentPeriod = period
|
||||
|
||||
if (!scheduleRepository.hasData(period)) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||
try {
|
||||
// force = true: hasData() already proved there is nothing cached for
|
||||
// this period, so never let the staleness window skip the fetch.
|
||||
scheduleRepository.syncPeriod(period.weekStart, force = true).collect { }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
if (revision != loadRevision) return
|
||||
if (!scheduleRepository.hasData(period)) {
|
||||
_uiState.value = _uiState.value.copy(isLoading = false, loadError = true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
weeksJob?.cancel()
|
||||
weeksJob = viewModelScope.launch {
|
||||
val weeks = scheduleRepository.weeksFor(period.weekStart)
|
||||
if (revision != loadRevision) return@launch
|
||||
_uiState.value = _uiState.value.copy(weeks = weeks)
|
||||
}
|
||||
|
||||
scheduleJob = viewModelScope.launch {
|
||||
scheduleRepository.observeSchedule(period).collect { schedule ->
|
||||
if (revision != loadRevision) {
|
||||
throw CancellationException("stale schedule observation")
|
||||
}
|
||||
currentTeacherName?.let { applySchedule(schedule, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applySchedule(schedule: TeacherSchedule?, teacherName: String) {
|
||||
val current = _uiState.value
|
||||
if (schedule == null) return
|
||||
|
||||
val filteredDays = schedule.daysForTeacher(teacherName)
|
||||
android.util.Log.d("TeacherSchedDebug", "apply: teacher=$teacherName periodWeek=$schedule.period rawDays=${schedule.days.size} filteredDays=${filteredDays.size}")
|
||||
filteredDays.forEach { d ->
|
||||
android.util.Log.d(
|
||||
"TeacherSchedDebug",
|
||||
"apply: filteredDay=${d.date} slots=${d.intervals.map { "${it.start}-${it.end}:${it.lessons.size}" }}"
|
||||
)
|
||||
}
|
||||
|
||||
val today = ScheduleDates.moscowToday()
|
||||
val todayDate = if (isInPeriod(today, schedule.period)) today else null
|
||||
|
||||
val dayDate = when {
|
||||
filteredDays.any { it.date == current.dayDate } -> current.dayDate
|
||||
filteredDays.any { it.date == todayDate } -> todayDate
|
||||
else -> filteredDays.firstOrNull()?.date
|
||||
}
|
||||
|
||||
preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString())
|
||||
dayDate?.let { preferencesRepository.setSelectedTeacherDayDate(it.toString()) }
|
||||
|
||||
_uiState.value = current.copy(
|
||||
schedule = schedule.copy(days = filteredDays),
|
||||
weekStart = schedule.period.weekStart,
|
||||
dayDate = dayDate,
|
||||
todayDate = todayDate,
|
||||
isLoading = false,
|
||||
loadError = false
|
||||
)
|
||||
}
|
||||
|
||||
fun selectWeek(weekStart: LocalDate) {
|
||||
val state = _uiState.value
|
||||
val name = state.teacherName ?: return
|
||||
if (state.weekStart == weekStart) return
|
||||
val revision = ++loadRevision
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString())
|
||||
if (revision != loadRevision) return@launch
|
||||
_uiState.value = _uiState.value.copy(
|
||||
weekStart = weekStart,
|
||||
dayDate = null,
|
||||
schedule = null,
|
||||
isLoading = true,
|
||||
loadError = false
|
||||
)
|
||||
val period = SchedulePeriod(
|
||||
weekStart = weekStart,
|
||||
weekEnd = weekStart.plusDays(6),
|
||||
season = ScheduleDates.currentSeason(weekStart)
|
||||
)
|
||||
loadSchedule(period, revision)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectDay(date: LocalDate) {
|
||||
val state = _uiState.value
|
||||
val schedule = state.schedule ?: return
|
||||
if (date.isBefore(schedule.period.weekStart) || date.isAfter(schedule.period.weekEnd)) return
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString())
|
||||
preferencesRepository.setSelectedTeacherDayDate(date.toString())
|
||||
_uiState.value = _uiState.value.copy(weekStart = schedule.period.weekStart, dayDate = date)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val period = currentPeriod ?: return
|
||||
val revision = ++loadRevision
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||
try {
|
||||
scheduleRepository.syncPeriod(ScheduleDates.moscowToday(), force = true).collect { }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
if (revision != loadRevision) return@launch
|
||||
loadSchedule(period, revision)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchTeacher() {
|
||||
loadRevision++
|
||||
viewModelScope.launch {
|
||||
currentTeacherName = null
|
||||
currentPeriod = null
|
||||
scheduleJob?.cancel()
|
||||
scheduleJob = null
|
||||
preferencesRepository.clearSelectedTeacher()
|
||||
_uiState.value = TeacherScheduleUiState(noTeacher = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isInPeriod(date: LocalDate, period: SchedulePeriod): Boolean =
|
||||
!date.isBefore(period.weekStart) && !date.isAfter(period.weekEnd)
|
||||
}
|
||||
|
||||
data class TeacherScheduleUiState(
|
||||
val noTeacher: Boolean = true,
|
||||
val isLoading: Boolean = false,
|
||||
val loadError: Boolean = false,
|
||||
val schedule: TeacherSchedule? = null,
|
||||
val teacherName: String? = null,
|
||||
val weekStart: LocalDate? = null,
|
||||
val dayDate: LocalDate? = null,
|
||||
val todayDate: LocalDate? = null,
|
||||
val weeks: List<SchedulePeriod> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,217 @@
|
||||
package agzam.vyatsu.update
|
||||
|
||||
import agzam.vyatsu.BuildConfig
|
||||
import agzam.vyatsu.data.remote.update.GiteaReleaseApi
|
||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
sealed interface UpdateState {
|
||||
data object Idle : UpdateState
|
||||
data object Checking : UpdateState
|
||||
data object UpToDate : UpdateState
|
||||
data class Available(val version: String, val notes: String?) : UpdateState
|
||||
data class Downloading(val progress: Int?) : UpdateState
|
||||
data object ReadyToInstall : UpdateState
|
||||
data class Failed(val message: String) : UpdateState
|
||||
}
|
||||
|
||||
class AppUpdater(
|
||||
private val context: Context,
|
||||
private val preferencesRepository: PreferencesRepository
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val api = GiteaReleaseApi(client)
|
||||
|
||||
private val _state = MutableStateFlow<UpdateState>(UpdateState.Idle)
|
||||
val state: StateFlow<UpdateState> = _state.asStateFlow()
|
||||
|
||||
private var job: Job? = null
|
||||
private var pendingUpdate: AvailableUpdate? = null
|
||||
private var downloadedApk: File? = null
|
||||
|
||||
fun autoCheckAndInstall() {
|
||||
if (job?.isActive == true) return
|
||||
job = scope.launch {
|
||||
if (!preferencesRepository.autoUpdateEnabled()) return@launch
|
||||
runCatching {
|
||||
val update = check() ?: return@launch
|
||||
install(download(update))
|
||||
}.onFailure(::onUpdateFailed)
|
||||
}
|
||||
}
|
||||
|
||||
fun checkForUpdate() {
|
||||
if (job?.isActive == true) return
|
||||
job = scope.launch {
|
||||
runCatching { check() }.onFailure(::onUpdateFailed)
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadAndInstall() {
|
||||
if (job?.isActive == true) return
|
||||
val update = pendingUpdate ?: return
|
||||
job = scope.launch {
|
||||
if (!preferencesRepository.autoUpdateEnabled()) return@launch
|
||||
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
|
||||
}
|
||||
}
|
||||
|
||||
fun installDownloadedApk() {
|
||||
val apk = downloadedApk ?: return
|
||||
install(apk)
|
||||
}
|
||||
|
||||
fun setAutoEnabled(enabled: Boolean) {
|
||||
if (!enabled) {
|
||||
job?.cancel()
|
||||
if (_state.value is UpdateState.Downloading || _state.value is UpdateState.Checking) {
|
||||
_state.value = UpdateState.Idle
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
preferencesRepository.setAutoUpdateEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun canRequestInstalls(): Boolean =
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
|
||||
fun openUnknownSourcesSettings() {
|
||||
val intent = Intent(
|
||||
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||
Uri.parse("package:${context.packageName}")
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(intent) }
|
||||
}
|
||||
|
||||
private suspend fun check(): AvailableUpdate? {
|
||||
_state.value = UpdateState.Checking
|
||||
val release = api.latestRelease()
|
||||
if (release == null) {
|
||||
_state.value = UpdateState.UpToDate
|
||||
return null
|
||||
}
|
||||
|
||||
val remoteVersion = release.tagName?.trimStart('v', 'V')
|
||||
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME.trimStart('v', 'V'))) {
|
||||
_state.value = UpdateState.UpToDate
|
||||
return null
|
||||
}
|
||||
|
||||
val apkUrl = api.apkDownloadUrl(release)
|
||||
?: throw IOException("В релизе $remoteVersion нет APK-файла")
|
||||
|
||||
val update = AvailableUpdate(version = remoteVersion, apkUrl = apkUrl)
|
||||
pendingUpdate = update
|
||||
_state.value = UpdateState.Available(update.version, release.body)
|
||||
return update
|
||||
}
|
||||
|
||||
private suspend fun download(update: AvailableUpdate): File {
|
||||
_state.value = UpdateState.Downloading(null)
|
||||
|
||||
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
val target = File(dir, "update-${update.version}.apk")
|
||||
downloadedApk?.delete()
|
||||
|
||||
val response = client.newCall(Request.Builder().url(update.apkUrl).get().build()).execute()
|
||||
response.use { resp ->
|
||||
if (!resp.isSuccessful) throw IOException("HTTP ${resp.code} при скачивании")
|
||||
val body = resp.body ?: throw IOException("Пустой ответ при скачивании")
|
||||
val contentLength = body.contentLength()
|
||||
|
||||
body.byteStream().use { input ->
|
||||
target.outputStream().use { output ->
|
||||
val buffer = ByteArray(BUFFER_SIZE_BYTES)
|
||||
var readTotal = 0L
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
output.write(buffer, 0, read)
|
||||
readTotal += read
|
||||
if (contentLength > 0) {
|
||||
_state.value = UpdateState.Downloading((readTotal * 100 / contentLength).toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target.length() == 0L) {
|
||||
target.delete()
|
||||
throw IOException("Скачанный файл пуст")
|
||||
}
|
||||
|
||||
downloadedApk = target
|
||||
_state.value = UpdateState.ReadyToInstall
|
||||
return target
|
||||
}
|
||||
|
||||
private fun install(apk: File) {
|
||||
if (!canRequestInstalls()) {
|
||||
return
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
apk
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
|
||||
data = uri
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
runCatching { context.startActivity(intent) }
|
||||
.onFailure { Log.e(TAG, "Не удалось запустить установку", it) }
|
||||
}
|
||||
|
||||
private fun onUpdateFailed(error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
Log.e(TAG, "Ошибка обновления", error)
|
||||
_state.value = UpdateState.Failed(error.message ?: "Неизвестная ошибка")
|
||||
}
|
||||
|
||||
private data class AvailableUpdate(val version: String, val apkUrl: String)
|
||||
|
||||
private fun isNewerVersion(remote: String, local: String): Boolean {
|
||||
val remoteParts = remote.split('.').map { it.toLongOrNull() ?: 0L }
|
||||
val localParts = local.split('.').map { it.toLongOrNull() ?: 0L }
|
||||
val size = maxOf(remoteParts.size, localParts.size)
|
||||
for (i in 0 until size) {
|
||||
val r = remoteParts.getOrElse(i) { 0L }
|
||||
val l = localParts.getOrElse(i) { 0L }
|
||||
if (r != l) return r > l
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "AppUpdate"
|
||||
const val BUFFER_SIZE_BYTES = 8 * 1024
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M482,800q-134,0 -228,-93t-94,-227v-7l-64,64 -56,-56 160,-160 160,160 -56,56 -64,-64v7q0,100 70.5,170T482,720q26,0 51,-6t49,-18l60,60q-38,22 -78,33t-82,11ZM760,639L600,479l56,-56 64,64v-7q0,-100 -70.5,-170T478,240q-26,0 -51,6t-49,18l-60,-60q38,-22 78,-33t82,-11q134,0 228,93t94,227v7l64,-64 56,56 -160,160Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M480,560q-17,0 -28.5,-11.5T440,520q0,-17 11.5,-28.5T480,480q17,0 28.5,11.5T520,520q0,17 -11.5,28.5T480,560ZM291.5,548.5Q280,537 280,520t11.5,-28.5Q303,480 320,480t28.5,11.5Q360,503 360,520t-11.5,28.5Q337,560 320,560t-28.5,-11.5ZM640,560q-17,0 -28.5,-11.5T600,520q0,-17 11.5,-28.5T640,480q17,0 28.5,11.5T680,520q0,17 -11.5,28.5T640,560ZM480,720q-17,0 -28.5,-11.5T440,680q0,-17 11.5,-28.5T480,640q17,0 28.5,11.5T520,680q0,17 -11.5,28.5T480,720ZM291.5,708.5Q280,697 280,680t11.5,-28.5Q303,640 320,640t28.5,11.5Q360,663 360,680t-11.5,28.5Q337,720 320,720t-28.5,-11.5ZM640,720q-17,0 -28.5,-11.5T600,680q0,-17 11.5,-28.5T640,640q17,0 28.5,11.5T680,680q0,17 -11.5,28.5T640,720ZM200,880q-33,0 -56.5,-23.5T120,800v-560q0,-33 23.5,-56.5T200,160h40v-80h80v80h320v-80h80v80h40q33,0 56.5,23.5T840,240v560q0,33 -23.5,56.5T760,880L200,880ZM200,800h560v-400L200,400v400Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M200,880q-33,0 -56.5,-23.5T120,800v-560q0,-33 23.5,-56.5T200,160h40v-80h80v80h320v-80h80v80h40q33,0 56.5,23.5T840,240v560q0,33 -23.5,56.5T760,880L200,880ZM200,800h560v-400L200,400v400ZM200,320h560v-80L200,240v80ZM200,320v-80,80ZM480,560q-17,0 -28.5,-11.5T440,520q0,-17 11.5,-28.5T480,480q17,0 28.5,11.5T520,520q0,17 -11.5,28.5T480,560ZM291.5,548.5Q280,537 280,520t11.5,-28.5Q303,480 320,480t28.5,11.5Q360,503 360,520t-11.5,28.5Q337,560 320,560t-28.5,-11.5ZM640,560q-17,0 -28.5,-11.5T600,520q0,-17 11.5,-28.5T640,480q17,0 28.5,11.5T680,520q0,17 -11.5,28.5T640,560ZM480,720q-17,0 -28.5,-11.5T440,680q0,-17 11.5,-28.5T480,640q17,0 28.5,11.5T520,680q0,17 -11.5,28.5T480,720ZM291.5,708.5Q280,697 280,680t11.5,-28.5Q303,640 320,640t28.5,11.5Q360,663 360,680t-11.5,28.5Q337,720 320,720t-28.5,-11.5ZM640,720q-17,0 -28.5,-11.5T600,680q0,-17 11.5,-28.5T640,640q17,0 28.5,11.5T680,680q0,17 -11.5,28.5T640,720Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M504,480 L320,296l56,-56 240,240 -240,240 -56,-56 184,-184Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M480,640 L280,440l56,-58 104,104v-326h80v326l104,-104 56,58 -200,200ZM240,800q-33,0 -56.5,-23.5T160,720v-120h80v120h480v-120h80v120q0,33 -23.5,56.5T720,800L240,800Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M382,640 L155,413l57,-57 170,170 366,-366 57,57 -423,423ZM200,800v-80h560v80L200,800Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="m388,748 l-56,-56 92,-92 -92,-92 56,-56 92,92 92,-92 56,56 -92,92 92,92 -56,56 -92,-92 -92,92ZM200,880q-33,0 -56.5,-23.5T120,800v-560q0,-33 23.5,-56.5T200,160h40v-80h80v80h320v-80h80v80h40q33,0 56.5,23.5T840,240v560q0,33 -23.5,56.5T760,880L200,880ZM200,800h560v-400L200,400v400ZM200,320h560v-80L200,240v80ZM200,320v-80,80Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M0,720v-63q0,-43 44,-70t116,-27q13,0 25,0.5t23,2.5q-14,21 -21,44t-7,48v65L0,720ZM240,720v-65q0,-32 17.5,-58.5T307,550q32,-20 76.5,-30t96.5,-10q53,0 97.5,10t76.5,30q32,20 49,46.5t17,58.5v65L240,720ZM780,720v-65q0,-26 -6.5,-49T754,563q11,-2 22.5,-2.5t23.5,-0.5q72,0 116,26.5t44,70.5v63L780,720ZM160,520q-33,0 -56.5,-23.5T80,440q0,-34 23.5,-57t56.5,-23q34,0 57,23t23,57q0,33 -23,56.5T160,520ZM800,520q-33,0 -56.5,-23.5T720,440q0,-34 23.5,-57t56.5,-23q34,0 57,23t23,57q0,33 -23,56.5T800,520ZM480,480q-50,0 -85,-35t-35,-85q0,-51 35,-85.5t85,-34.5q51,0 85.5,34.5T600,360q0,50 -34.5,85T480,480Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M0,720v-63q0,-43 44,-70t116,-27q13,0 25,0.5t23,2.5q-14,21 -21,44t-7,48v65L0,720ZM240,720v-65q0,-32 17.5,-58.5T307,550q32,-20 76.5,-30t96.5,-10q53,0 97.5,10t76.5,30q32,20 49,46.5t17,58.5v65L240,720ZM780,720v-65q0,-26 -6.5,-49T754,563q11,-2 22.5,-2.5t23.5,-0.5q72,0 116,26.5t44,70.5v63L780,720ZM325,640h311q-10,-20 -55.5,-35T480,590q-55,0 -100.5,15T325,640ZM160,520q-33,0 -56.5,-23.5T80,440q0,-34 23.5,-57t56.5,-23q34,0 57,23t23,57q0,33 -23,56.5T160,520ZM800,520q-33,0 -56.5,-23.5T720,440q0,-34 23.5,-57t56.5,-23q34,0 57,23t23,57q0,33 -23,56.5T800,520ZM480,480q-50,0 -85,-35t-35,-85q0,-51 35,-85.5t85,-34.5q51,0 85.5,34.5T600,360q0,50 -34.5,85T480,480ZM480,400q17,0 28.5,-11.5T520,360q0,-17 -11.5,-28.5T480,320q-17,0 -28.5,11.5T440,360q0,17 11.5,28.5T480,400ZM481,640ZM480,360Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,109 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:viewportWidth="20"
|
||||
android:viewportHeight="20">
|
||||
<path
|
||||
android:pathData="M1,6L1,18L19,18L19,6"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="0"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="1"
|
||||
android:startY="18"
|
||||
android:endX="19"
|
||||
android:endY="6"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF5581D5"/>
|
||||
<item android:offset="1" android:color="#FF4063CD"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:startX="1"
|
||||
android:startY="18"
|
||||
android:endX="19"
|
||||
android:endY="6"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF5581D5"/>
|
||||
<item android:offset="1" android:color="#FF4063CD"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M19,18L1,18C12,18 19,14 19,6Z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="0"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="1"
|
||||
android:startY="18"
|
||||
android:endX="19"
|
||||
android:endY="6"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF6598E3"/>
|
||||
<item android:offset="1" android:color="#FF74A2E6"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:startX="1"
|
||||
android:startY="18"
|
||||
android:endX="19"
|
||||
android:endY="6"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF6598E3"/>
|
||||
<item android:offset="1" android:color="#FF74A2E6"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M4,4C4,2 6,2 6,4L14,4C14,2 16,2 16,4L19,4L19,6L1,6L1,4Z"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="0"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="1"
|
||||
android:startY="2.5"
|
||||
android:endX="19"
|
||||
android:endY="2.5"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF4260C4"/>
|
||||
<item android:offset="1" android:color="#FF0E2CB0"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:startX="1"
|
||||
android:startY="2.5"
|
||||
android:endX="19"
|
||||
android:endY="2.5"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF4260C4"/>
|
||||
<item android:offset="1" android:color="#FF0E2CB0"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M3,8L17,8C18,8 18,9 17,9L3,9C2,9 2,8 3,8M3,11L14,11C15,11 15,12 14,12L3,12C2,12 2,11 3,11M4,14L12,14C13,14 13,15 12,15L5,15C4,15 4,14 5,14M3,14C4,14 4,15 3,15C2,15 2,14 3,14"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="0"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeLineCap="round">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="2.25"
|
||||
android:startY="8"
|
||||
android:endX="17.75"
|
||||
android:endY="8"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF4260C4"/>
|
||||
<item android:offset="1" android:color="#FF0E2CB0"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M440,680h80v-240h-80v240ZM508.5,348.5Q520,337 520,320t-11.5,-28.5Q497,280 480,280t-28.5,11.5Q440,303 440,320t11.5,28.5Q463,360 480,360t28.5,-11.5ZM480,880q-83,0 -156,-31.5T197,763q-54,-54 -85.5,-127T80,480q0,-83 31.5,-156T197,197q54,-54 127,-85.5T480,80q83,0 156,31.5T763,197q54,54 85.5,127T880,480q0,83 -31.5,156T763,763q-54,54 -127,85.5T480,880ZM480,800q134,0 227,-93t93,-227q0,-134 -93,-227t-227,-93q-134,0 -227,93t-93,227q0,134 93,227t227,93ZM480,480Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M536.5,456.5Q560,433 560,400t-23.5,-56.5Q513,320 480,320t-56.5,23.5Q400,367 400,400t23.5,56.5Q447,480 480,480t56.5,-23.5ZM480,880Q319,743 239.5,625.5T160,408q0,-150 96.5,-239T480,80q127,0 223.5,89T800,408q0,100 -79.5,217.5T480,880Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M536.5,456.5Q560,433 560,400t-23.5,-56.5Q513,320 480,320t-56.5,23.5Q400,367 400,400t23.5,56.5Q447,480 480,480t56.5,-23.5ZM480,774q122,-112 181,-203.5T720,408q0,-109 -69.5,-178.5T480,160q-101,0 -170.5,69.5T240,408q0,71 59,162.5T480,774ZM480,880Q319,743 239.5,625.5T160,408q0,-150 96.5,-239T480,80q127,0 223.5,89T800,408q0,100 -79.5,217.5T480,880ZM480,400Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M160,760v-80h80v-280q0,-83 50,-147.5T420,168v-28q0,-25 17.5,-42.5T480,80q25,0 42.5,17.5T540,140v28q80,20 130,84.5T720,400v280h80v80L160,760ZM480,880q-33,0 -56.5,-23.5T400,800h160q0,33 -23.5,56.5T480,880ZM80,400q0,-100 44.5,-183.5T244,78l47,64q-60,44 -95.5,111T160,400L80,400ZM800,400q0,-80 -35.5,-147T669,142l47,-64q75,55 119.5,138.5T880,400h-80Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M80,400q0,-100 44.5,-183.5T244,78l47,64q-60,44 -95.5,111T160,400L80,400ZM800,400q0,-80 -35.5,-147T669,142l47,-64q75,55 119.5,138.5T880,400h-80ZM160,760v-80h80v-280q0,-83 50,-147.5T420,168v-28q0,-25 17.5,-42.5T480,80q25,0 42.5,17.5T540,140v28q80,20 130,84.5T720,400v280h80v80L160,760ZM480,460ZM480,880q-33,0 -56.5,-23.5T400,800h160q0,33 -23.5,56.5T480,880ZM320,680h320v-280q0,-66 -47,-113t-113,-47q-66,0 -113,47t-47,113v280Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M367,433q-47,-47 -47,-113t47,-113q47,-47 113,-47t113,47q47,47 47,113t-47,113q-47,47 -113,47t-113,-47ZM160,800v-112q0,-34 17.5,-62.5T224,582q62,-31 126,-46.5T480,520q66,0 130,15.5T736,582q29,15 46.5,43.5T800,688v112L160,800Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M367,433q-47,-47 -47,-113t47,-113q47,-47 113,-47t113,47q47,47 47,113t-47,113q-47,47 -113,47t-113,-47ZM160,800v-112q0,-34 17.5,-62.5T224,582q62,-31 126,-46.5T480,520q66,0 130,15.5T736,582q29,15 46.5,43.5T800,688v112L160,800ZM240,720h480v-32q0,-11 -5.5,-20T700,654q-54,-27 -109,-40.5T480,600q-56,0 -111,13.5T260,654q-9,5 -14.5,14t-5.5,20v32ZM536.5,376.5Q560,353 560,320t-23.5,-56.5Q513,240 480,240t-56.5,23.5Q400,287 400,320t23.5,56.5Q447,400 480,400t56.5,-23.5ZM480,320ZM480,720Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M480,800q-134,0 -227,-93t-93,-227q0,-134 93,-227t227,-93q69,0 132,28.5T720,270v-110h80v280L520,440v-80h168q-32,-56 -87.5,-88T480,240q-100,0 -170,70t-70,170q0,100 70,170t170,70q77,0 139,-44t87,-116h84q-28,106 -114,173t-196,67Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="m370,880 l-16,-128q-13,-5 -24.5,-12T307,725l-119,50L78,585l103,-78q-1,-7 -1,-13.5v-27q0,-6.5 1,-13.5L78,375l110,-190 119,50q11,-8 23,-15t24,-12l16,-128h220l16,128q13,5 24.5,12t22.5,15l119,-50 110,190 -103,78q1,7 1,13.5v27q0,6.5 -2,13.5l103,78 -110,190 -118,-50q-11,8 -23,15t-24,12L590,880L370,880ZM482,620q58,0 99,-41t41,-99q0,-58 -41,-99t-99,-41q-59,0 -99.5,41T342,480q0,58 40.5,99t99.5,41Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="m370,880 l-16,-128q-13,-5 -24.5,-12T307,725l-119,50L78,585l103,-78q-1,-7 -1,-13.5v-27q0,-6.5 1,-13.5L78,375l110,-190 119,50q11,-8 23,-15t24,-12l16,-128h220l16,128q13,5 24.5,12t22.5,15l119,-50 110,190 -103,78q1,7 1,13.5v27q0,6.5 -2,13.5l103,78 -110,190 -118,-50q-11,8 -23,15t-24,12L590,880L370,880ZM440,800h79l14,-106q31,-8 57.5,-23.5T639,633l99,41 39,-68 -86,-65q5,-14 7,-29.5t2,-31.5q0,-16 -2,-31.5t-7,-29.5l86,-65 -39,-68 -99,42q-22,-23 -48.5,-38.5T533,266l-13,-106h-79l-14,106q-31,8 -57.5,23.5T321,327l-99,-41 -39,68 86,64q-5,15 -7,30t-2,32q0,16 2,31t7,30l-86,65 39,68 99,-42q22,23 48.5,38.5T427,694l13,106ZM482,620q58,0 99,-41t41,-99q0,-58 -41,-99t-99,-41q-59,0 -99.5,41T342,480q0,58 40.5,99t99.5,41ZM480,480Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M280,840 L80,640l200,-200 57,56 -104,104h607v80L233,680l104,104 -57,56ZM680,520 L623,464 727,360L120,360v-80h607L623,176l57,-56 200,200 -200,200Z"
|
||||
android:fillColor="#e3e3e3"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100px" height="100px" viewBox="-10 -10 20 20">
|
||||
<defs>
|
||||
<linearGradient id="accent">
|
||||
<stop offset="0%" stop-color="#4260c4" />
|
||||
<stop offset="100%" stop-color="#0e2cb0" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="bg" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5581d5" />
|
||||
<stop offset="100%" stop-color="#4063cd" />
|
||||
</linearGradient>
|
||||
|
||||
<linearGradient id="bga" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#6598e3" />
|
||||
<stop offset="100%" stop-color="#74a2e6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g stroke-width="0" stroke-linejoin="round" stroke-linecap="round">
|
||||
<!-- <path stroke="#111" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/> -->
|
||||
|
||||
<path stroke="url(#bg)" fill="url(#bg)" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/>
|
||||
<path stroke="url(#bga)" fill="url(#bga)" d="M 9 8 L -9 8 C 2 8 9 4 9 -4 Z"/>
|
||||
|
||||
<!-- <path stroke="#111" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/> -->
|
||||
|
||||
<g stroke="url(#accent)">
|
||||
<path fill="url(#accent)" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/>
|
||||
|
||||
<path fill="url(#accent)" stroke="none" d="M -7 -2 L 7 -2 C 8 -2 8 -1 7 -1 L -7 -1 C -8 -1 -8 -2 -7 -2 M -7 1 L 4 1 C 5 1 5 2 4 2 L -7 2 C -8 2 -8 1 -7 1 M -6 4 L 2 4 C 3 4 3 5 2 5 L -5 5 C -6 5 -6 4 -5 4 M -7 4 C -6 4 -6 5 -7 5 C -8 5 -8 4 -7 4"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,27 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUAMEYxCzAJBgNV
|
||||
BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQDExNHbG9iYWxTaWduIFJv
|
||||
b3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAX
|
||||
BgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIi
|
||||
MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08Es
|
||||
CVeJOaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQGvGIFAha/
|
||||
r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud316HCkD7rRlr+/fKYIje
|
||||
2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo0q3v84RLHIf8E6M6cqJaESvWJ3En7YEt
|
||||
bWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSEy132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvj
|
||||
K8Cd+RTyG/FWaha/LIWFzXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD4
|
||||
12lPFzYE+cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCNI/on
|
||||
ccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzsx2sZy/N78CsHpdls
|
||||
eVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqaByFrgY/bxFn63iLABJzjqls2k+g9
|
||||
vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYD
|
||||
VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEM
|
||||
BQADggIBAHx47PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg
|
||||
JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti2kM3S+LGteWy
|
||||
gxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIkpnnpHs6i58FZFZ8d4kuaPp92
|
||||
CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRFFRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZm
|
||||
OUdkLG5NrmJ7v2B0GbhWrJKsFjLtrWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qq
|
||||
JZ4d16GLuc1CLgSkZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwye
|
||||
qiv5u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP4vkYxboz
|
||||
nxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6N3ec592kD3ZDZopD8p/7
|
||||
DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3vouXsXgxT7PntgMTzlSdriVZzH81Xwj3
|
||||
QEUxeCp6
|
||||
-----END CERTIFICATE-----
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">VyatsuSchedule</string>
|
||||
<string name="app_name">ВятГУ Расписание</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="updates"
|
||||
path="updates/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Сертификат vyatsu.ru перевыпущен 05.10.2026 и теперь выдан в иерархии
|
||||
GlobalSign Root R46, которая в системный trust store Android попала
|
||||
только в Android 14. На устройствах с Android 13 и ниже все HTTPS-запросы
|
||||
к сайту падают с "Trust anchor for certification path not found".
|
||||
|
||||
Добавляем этот корень как дополнительный якорь доверия ТОЛЬКО для доменов
|
||||
vyatsu.ru — системные корни сохраняются, остальные сайты не затронуты.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
<cleartextTrafficPermitted>false</cleartextTrafficPermitted>
|
||||
</base-config>
|
||||
<domain-config>
|
||||
<domain includeSubdomains="true">vyatsu.ru</domain>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="@raw/globalsign_root_r46" />
|
||||
</trust-anchors>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,19 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.data.remote.GroupsParser
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GroupsPipelineMemoryTest {
|
||||
|
||||
@Test
|
||||
fun streamingUnderSmallHeap() {
|
||||
val fixture = checkNotNull(javaClass.getResourceAsStream("/groups.html")) {
|
||||
"Fixture groups.html not found in src/test/resources"
|
||||
}
|
||||
val result = fixture.use(GroupsParser::parseStream)
|
||||
assertTrue(result.groups.size > 400)
|
||||
assertTrue(result.groups.any { it.id == "26981" && it.name.contains("ПОДб-1701-08-00") })
|
||||
assertTrue(result.dateRanges.size > 400)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.Lesson
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import agzam.vyatsu.domain.model.TeacherSchedule
|
||||
import agzam.vyatsu.domain.model.TimeSlot
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
|
||||
class TeacherScheduleDomainTest {
|
||||
|
||||
@Test
|
||||
fun daysForTeacherFiltersAndPreservesOrder() {
|
||||
val date1 = LocalDate.of(2026, 9, 14)
|
||||
val date2 = LocalDate.of(2026, 9, 15)
|
||||
val slot1 = TimeSlot(
|
||||
LocalTime.of(8, 20),
|
||||
LocalTime.of(9, 50),
|
||||
listOf(lesson(room = "4-302", teacher = "A"), lesson(room = "5-100", teacher = "B"))
|
||||
)
|
||||
val slot2 = TimeSlot(
|
||||
LocalTime.of(10, 0),
|
||||
LocalTime.of(11, 30),
|
||||
listOf(lesson(room = "5-100", teacher = "B"))
|
||||
)
|
||||
val day2Slot = TimeSlot(
|
||||
LocalTime.of(8, 20),
|
||||
LocalTime.of(9, 50),
|
||||
listOf(lesson(room = "5-100", teacher = "B"))
|
||||
)
|
||||
val schedule = TeacherSchedule(
|
||||
period = SchedulePeriod(date1, date2, Season.AUTUMN),
|
||||
days = listOf(Day(date1, listOf(slot1, slot2)), Day(date2, listOf(day2Slot)))
|
||||
)
|
||||
|
||||
val result = schedule.daysForTeacher("A")
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(date1, result[0].date)
|
||||
assertEquals(1, result[0].intervals.size)
|
||||
assertEquals(LocalTime.of(8, 20), result[0].intervals[0].start)
|
||||
assertEquals(1, result[0].intervals[0].lessons.size)
|
||||
assertEquals("A", result[0].intervals[0].lessons[0].teacher)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun daysForTeacherDropsDayWithoutTeacher() {
|
||||
val date1 = LocalDate.of(2026, 9, 14)
|
||||
val date2 = LocalDate.of(2026, 9, 15)
|
||||
val schedule = TeacherSchedule(
|
||||
period = SchedulePeriod(date1, date2, Season.AUTUMN),
|
||||
days = listOf(
|
||||
Day(date1, listOf(slotOf(lesson(room = "4-302", teacher = "A")))),
|
||||
Day(date2, listOf(slotOf(lesson(room = "5-100", teacher = "B"))))
|
||||
)
|
||||
)
|
||||
|
||||
val result = schedule.daysForTeacher("A")
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(date1, result[0].date)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dedupCollapsesOnlyIdenticalLessons() {
|
||||
val date = LocalDate.of(2026, 9, 14)
|
||||
val raw = "4-302 Программирование Лекция ИВТб-3304-01-00"
|
||||
val identical = lesson(
|
||||
room = "4-302",
|
||||
group = "ИВТб-3304-01-00",
|
||||
teacher = "A",
|
||||
raw = raw,
|
||||
subject = "Программирование"
|
||||
)
|
||||
val differsInGroup = identical.copy(group = "ИВТб-3304-02-00")
|
||||
val differsInRoom = identical.copy(room = "5-102")
|
||||
|
||||
val schedule = TeacherSchedule(
|
||||
period = SchedulePeriod(date, date, Season.AUTUMN),
|
||||
days = listOf(
|
||||
Day(
|
||||
date,
|
||||
listOf(
|
||||
TimeSlot(
|
||||
LocalTime.of(8, 20),
|
||||
LocalTime.of(9, 50),
|
||||
listOf(identical, identical, differsInGroup, differsInRoom)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = schedule.daysForTeacher("A")
|
||||
|
||||
val lessons = result[0].intervals[0].lessons
|
||||
assertEquals(3, lessons.size)
|
||||
assertEquals(1, lessons.count { it == identical })
|
||||
assertEquals(1, lessons.count { it.room == "4-302" && it.group == "ИВТб-3304-01-00" })
|
||||
assertTrue(lessons.any { it.group == "ИВТб-3304-02-00" })
|
||||
assertTrue(lessons.any { it.room == "5-102" })
|
||||
}
|
||||
|
||||
private fun slotOf(lesson: Lesson) =
|
||||
TimeSlot(LocalTime.of(8, 20), LocalTime.of(9, 50), listOf(lesson))
|
||||
|
||||
private fun lesson(
|
||||
room: String,
|
||||
group: String = "ИВТб-3304-01-00",
|
||||
teacher: String,
|
||||
raw: String = "$room Программирование Лекция $group",
|
||||
subject: String = "Программирование"
|
||||
) = Lesson(
|
||||
raw = raw,
|
||||
room = room,
|
||||
group = group,
|
||||
subject = subject,
|
||||
type = LessonType.LECTURE,
|
||||
teacher = teacher
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.data.remote.TeacherScheduleParser
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TeacherScheduleParserTest {
|
||||
|
||||
private fun fixture(): String =
|
||||
checkNotNull(javaClass.getResourceAsStream("/prepod.html"))
|
||||
.readBytes().toString(Charsets.UTF_8)
|
||||
|
||||
private fun fixture517(): String =
|
||||
checkNotNull(javaClass.getResourceAsStream("/prepod517.html"))
|
||||
.readBytes().toString(Charsets.UTF_8)
|
||||
|
||||
private fun fixture974(): String =
|
||||
checkNotNull(javaClass.getResourceAsStream("/prepod974.html"))
|
||||
.readBytes().toString(Charsets.UTF_8)
|
||||
|
||||
@Test
|
||||
fun teachersHeaderParsedInOrderAndDeduped() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture())
|
||||
assertEquals(listOf("Лелекова Е.В.", "Иванов И.И.", "Петрова А.С."), parsed.teachers)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun daysWithCorrectDates() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture())
|
||||
assertEquals(2, parsed.days.size)
|
||||
assertEquals(LocalDate.of(2026, 9, 14), parsed.days[0].date)
|
||||
assertEquals(LocalDate.of(2026, 9, 15), parsed.days[1].date)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalTimesParsed() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture())
|
||||
val day0 = parsed.days[0]
|
||||
assertEquals(2, day0.intervals.size)
|
||||
assertEquals(LocalTime.of(8, 20), day0.intervals[0].start)
|
||||
assertEquals(LocalTime.of(9, 50), day0.intervals[0].end)
|
||||
assertEquals(LocalTime.of(10, 0), day0.intervals[1].start)
|
||||
assertEquals(LocalTime.of(11, 30), day0.intervals[1].end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multiGroupCellSplitsIntoPerGroupLessons() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture())
|
||||
val cell = parsed.days[0].intervals[0].lessons.filter { it.teacher == "Лелекова Е.В." }
|
||||
assertEquals(2, cell.size)
|
||||
val lesson0 = cell[0]
|
||||
val lesson1 = cell[1]
|
||||
assertEquals("Бб-1101-04-00", lesson0.group)
|
||||
assertNull(lesson0.subgroup)
|
||||
assertEquals("01 подгруппа", lesson1.subgroup)
|
||||
assertEquals("4-302", lesson0.room)
|
||||
assertEquals("Биоразнообразие", lesson0.subject)
|
||||
assertEquals(LessonType.LECTURE, lesson0.type)
|
||||
assertEquals("Лелекова Е.В.", lesson0.teacher)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyCellContributesNoLesson() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture())
|
||||
val threeSlots = (0..6).map { slot ->
|
||||
parsed.days[0].intervals.firstOrNull { it.start.hour == 8 && it.start.minute == 20 } ?: return@map true
|
||||
}
|
||||
val petrovLessons = parsed.days[0].intervals[0].lessons.filter { it.teacher == "Петрова А.С." }
|
||||
assertEquals(0, petrovLessons.size)
|
||||
assertTrue(threeSlots.isEmpty() || threeSlots.size == 7)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noRoomCellKeepsRawAndNullRoom() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture())
|
||||
val cell = parsed.days[0].intervals[1].lessons.filter { it.teacher == "Лелекова Е.В." }
|
||||
assertEquals(2, cell.size)
|
||||
assertNull(cell[0].room)
|
||||
assertTrue(cell[0].raw.contains("Бб-1101-04-00"))
|
||||
assertNull(cell[0].subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactTotalLessonCount() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture())
|
||||
val total = parsed.days.sumOf { day -> day.intervals.sumOf { it.lessons.size } }
|
||||
assertEquals(11, total)
|
||||
}
|
||||
|
||||
private fun spacerColumnFixture(): String = """
|
||||
<table>
|
||||
<tr>
|
||||
<td>День</td><td>Интервал</td><td></td>
|
||||
<td><span>Марков Р.В.</span></td><td><span>Мельцов В.Ю.</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>четверг 08.10.26</td><td>08:20-09:50</td><td></td>
|
||||
<td>2-206 Математика Практическое занятие УТб-2301-02-00</td>
|
||||
<td>1-334 Организация параллельных вычислительных систем Лекция ИВТб-4301-05-00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>10:00-11:30</td><td></td>
|
||||
<td>2-206 Математика Практическое занятие УТб-2301-02-00</td>
|
||||
<td>1-334 Организация параллельных вычислительных систем Лекция ИВТб-4301-05-00</td>
|
||||
</tr>
|
||||
</table>
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun emptyHeaderSpacerDoesNotShiftTeacherColumns() {
|
||||
val parsed = TeacherScheduleParser.parse(spacerColumnFixture())
|
||||
assertEquals(listOf("Марков Р.В.", "Мельцов В.Ю."), parsed.teachers)
|
||||
assertEquals(1, parsed.days.size)
|
||||
assertEquals(LocalDate.of(2026, 10, 8), parsed.days[0].date)
|
||||
assertEquals(2, parsed.days[0].intervals.size)
|
||||
for (slot in parsed.days[0].intervals) {
|
||||
val markov = slot.lessons.filter { it.teacher == "Марков Р.В." }
|
||||
val meltsov = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
|
||||
assertEquals(1, markov.size)
|
||||
assertEquals(1, meltsov.size)
|
||||
assertEquals("Математика", markov[0].subject)
|
||||
assertEquals("Организация параллельных вычислительных систем", meltsov[0].subject)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realPage517TeacherColumnsAligned() {
|
||||
val html = checkNotNull(javaClass.getResourceAsStream("/prepod517.html"))
|
||||
.readBytes().toString(Charsets.UTF_8)
|
||||
val parsed = TeacherScheduleParser.parse(html)
|
||||
assertTrue(parsed.teachers.contains("Мельцов В.Ю."))
|
||||
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 8) }
|
||||
val all = day.intervals.flatMap { it.lessons }
|
||||
val markov = all.filter { it.teacher == "Марков Р.В." }
|
||||
val meltsov = all.filter { it.teacher == "Мельцов В.Ю." }
|
||||
assertTrue("Мельцов должен иметь пары 08.10", meltsov.isNotEmpty())
|
||||
assertTrue(
|
||||
"У Мельцова не должно быть математики Маркова",
|
||||
meltsov.none { it.subject?.contains("Математика") == true }
|
||||
)
|
||||
assertTrue(
|
||||
"У Маркова должна остаться математика",
|
||||
markov.any { it.subject?.contains("Математика") == true }
|
||||
)
|
||||
assertTrue(
|
||||
"У Мельцова параллельные вычислительные системы",
|
||||
meltsov.any { it.subject?.contains("Организация параллельных") == true }
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realPage517MeltsovLabsSitAt1000And1145NotAt0820() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture517())
|
||||
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 6) }
|
||||
val labsAt = { time: java.time.LocalTime ->
|
||||
day.intervals.firstOrNull { it.start == time }
|
||||
?.lessons
|
||||
?.filter { it.teacher == "Мельцов В.Ю." && it.type == LessonType.LABORATORY }
|
||||
.orEmpty()
|
||||
}
|
||||
assertEquals(
|
||||
"В 08:20 у Мельцова на странице 517 лабораторных нет",
|
||||
0, labsAt(java.time.LocalTime.of(8, 20)).size
|
||||
)
|
||||
assertTrue(labsAt(java.time.LocalTime.of(10, 0)).isNotEmpty())
|
||||
assertTrue(labsAt(java.time.LocalTime.of(11, 45)).isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realPage974MeltsovHasPracticeAt0820() {
|
||||
val parsed = TeacherScheduleParser.parse(fixture974())
|
||||
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 6) }
|
||||
val slot = day.intervals.first { it.start == java.time.LocalTime.of(8, 20) }
|
||||
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
|
||||
assertTrue(mel.isNotEmpty())
|
||||
assertTrue("08:20 на странице 974 — практическое занятие", mel.all { it.type == LessonType.PRACTICE })
|
||||
assertEquals("ИВТб-2305-06-00", mel.first().group)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package agzam.vyatsu
|
||||
|
||||
import agzam.vyatsu.data.remote.TeachersParser
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import java.io.InputStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TeachersPipelineMemoryTest {
|
||||
|
||||
private fun fixture(): InputStream =
|
||||
checkNotNull(javaClass.getResourceAsStream("/teacher.html")) {
|
||||
"Fixture teacher.html not found in src/test/resources"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesDepartmentsAndFaculties() {
|
||||
val result = fixture().use(TeachersParser::parseStream)
|
||||
assertTrue("expected >=4 departments", result.departments.size >= 4)
|
||||
|
||||
val ids = result.departments.map { it.departmentId }.toSet()
|
||||
assertEquals(setOf("11111", "22222", "33333", "44444"), ids)
|
||||
|
||||
val names = result.departments.map { it.name }
|
||||
assertTrue(
|
||||
"nested tags must be stripped from kafPeriod names",
|
||||
names.contains("Кафедра технологий и конструирования машин и аппаратов")
|
||||
)
|
||||
|
||||
val avt = result.departments.first { it.departmentId == "11111" }
|
||||
assertTrue("faculty must be mapped via shortname", avt.faculty.contains("ФАВТ"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chunkBoundarySafe() {
|
||||
val full = fixture().use(TeachersParser::parseStream)
|
||||
val limited = limitReads(fixture(), 64).use(TeachersParser::parseStream)
|
||||
assertEquals(full, limited)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seasonAndRanges() {
|
||||
val result = fixture().use(TeachersParser::parseStream)
|
||||
|
||||
val hasAutumn = result.dateRanges.values.any { it[Season.AUTUMN]?.isNotEmpty() == true }
|
||||
val hasSpring = result.dateRanges.values.any { it[Season.SPRING]?.isNotEmpty() == true }
|
||||
assertTrue("AUTUMN season must be present", hasAutumn)
|
||||
assertTrue("SPRING season must be present", hasSpring)
|
||||
|
||||
val autumn = result.dateRanges["11111"]?.get(Season.AUTUMN).orEmpty()
|
||||
assertTrue(
|
||||
"DateRange must be parsed from URL groups",
|
||||
autumn.contains(DateRange("01092026", "13092026"))
|
||||
)
|
||||
assertTrue(
|
||||
"second AUTUMN range must be present",
|
||||
autumn.contains(DateRange("14092026", "27092026"))
|
||||
)
|
||||
|
||||
val spring = result.dateRanges["44444"]?.get(Season.SPRING).orEmpty()
|
||||
assertTrue(
|
||||
"SPRING DateRange must be parsed from URL groups",
|
||||
spring.contains(DateRange("02022026", "27022026"))
|
||||
)
|
||||
}
|
||||
|
||||
private fun limitReads(input: InputStream, maxBytes: Int): InputStream =
|
||||
object : InputStream() {
|
||||
override fun read(): Int = input.read()
|
||||
|
||||
override fun read(b: ByteArray, off: Int, len: Int): Int =
|
||||
input.read(b, off, minOf(len, maxBytes))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user