Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8af24d06e3 | ||
|
|
4e180c8b9b |
@@ -18,19 +18,42 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
releases: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Verify Environment
|
||||
- name: Set up JDK 17
|
||||
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
||||
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
||||
run: |
|
||||
java -version
|
||||
echo "SDK path: $ANDROID_HOME"
|
||||
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"
|
||||
|
||||
- name: Grant execute permission for gradlew
|
||||
run: |
|
||||
chmod +x gradlew
|
||||
java -version
|
||||
|
||||
# Версия по дате (ГГГГ.ММ.ДД), при совпадении тега добавляем уникальный суффикс.
|
||||
- name: Generate version from date
|
||||
@@ -43,9 +66,9 @@ jobs:
|
||||
SUFFIX=$((SUFFIX + 1))
|
||||
TAG="v${BASE}.${SUFFIX}"
|
||||
done
|
||||
echo "version_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version_name=$BASE" >> "$GITHUB_OUTPUT"
|
||||
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Generated version: $BASE"
|
||||
echo "Generated version: $BASE (tag: $TAG)"
|
||||
|
||||
- name: Restore keystore from secret
|
||||
# Ключ подписи передаётся как base64-секрет и декодируется в файл.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# ВятГУ расписание _(неофициальный Android клиент)_
|
||||
# vyatsu-schedulegit
|
||||
|
||||
## settings-branch
|
||||
blank settings at first, soon will add update feature.
|
||||
|
||||
|
||||
Расписание загружается и парсится прямиком с сайта ВятГУ, и сохраняется в локальную БД телефона без промежуточных серверов
|
||||
|
||||
- Расписание групп - парсится из PDF-ок ([тута](https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/raspisanie-zanyatiy-dlya-studentov.html))
|
||||
- Расписание преподавателей - загружаются все расписания преподавателей [отсюда](https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/teacher.html) на выбранный переиод (ибо раскиданы по кафедрам)
|
||||
settings will be presetned as fourth button on the bottom of the screen.
|
||||
@@ -19,7 +19,7 @@ android {
|
||||
versionCode = 1
|
||||
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
|
||||
// Локально и при отсутствии переменной — fallback "1.0".
|
||||
versionName = System.getenv("APP_VERSION") ?: "debug"
|
||||
versionName = System.getenv("APP_VERSION") ?: "1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -40,8 +40,7 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
@@ -54,7 +53,6 @@ android {
|
||||
}
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
unitTests.all { it.maxHeapSize = "16m" }
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
@@ -117,6 +115,4 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -19,22 +19,3 @@
|
||||
# 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>(...);
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "3fd5eda9ac5e29fc96371ecf246e437c",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "groups",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "faculty",
|
||||
"columnName": "faculty",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncedAt",
|
||||
"columnName": "lastSyncedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "group_periods",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "groupId",
|
||||
"columnName": "groupId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "season",
|
||||
"columnName": "season",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "weekStart",
|
||||
"columnName": "weekStart",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "weekEnd",
|
||||
"columnName": "weekEnd",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_group_periods_groupId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"groupId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "groups",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"groupId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "schedules",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "groupId",
|
||||
"columnName": "groupId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "season",
|
||||
"columnName": "season",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "weekStart",
|
||||
"columnName": "weekStart",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "weekEnd",
|
||||
"columnName": "weekEnd",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncedAt",
|
||||
"columnName": "lastSyncedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_schedules_groupId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"groupId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_schedules_groupId_season_weekStart",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"groupId",
|
||||
"season",
|
||||
"weekStart"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "groups",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"groupId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "days",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "scheduleId",
|
||||
"columnName": "scheduleId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "date",
|
||||
"columnName": "date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_days_scheduleId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"scheduleId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_days_date",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"date"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "schedules",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"scheduleId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "lessons",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "dayId",
|
||||
"columnName": "dayId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "date",
|
||||
"columnName": "date",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "slotIndex",
|
||||
"columnName": "slotIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "start",
|
||||
"columnName": "start",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "end",
|
||||
"columnName": "end",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lessonIndex",
|
||||
"columnName": "lessonIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "raw",
|
||||
"columnName": "raw",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "room",
|
||||
"columnName": "room",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "groupName",
|
||||
"columnName": "groupName",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subgroup",
|
||||
"columnName": "subgroup",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "subject",
|
||||
"columnName": "subject",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "teacher",
|
||||
"columnName": "teacher",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_lessons_dayId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"dayId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_lessons_date",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"date"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
|
||||
},
|
||||
{
|
||||
"name": "index_lessons_subject",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"subject"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
|
||||
},
|
||||
{
|
||||
"name": "index_lessons_room",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"room"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "days",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"dayId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "preferences",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"key"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3fd5eda9ac5e29fc96371ecf246e437c')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,818 +0,0 @@
|
||||
{
|
||||
"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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,8 @@
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@drawable/icon"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
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">
|
||||
|
||||
|
After Width: | Height: | Size: 234 KiB |
@@ -1,15 +1,11 @@
|
||||
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
|
||||
@@ -24,51 +20,22 @@ fun AppRoot() {
|
||||
val container = app.container
|
||||
|
||||
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||
val teacherScheduleViewModel: TeacherScheduleViewModel = viewModel(factory = teacherScheduleFactory(container))
|
||||
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(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 (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) }
|
||||
if (state.noGroup) {
|
||||
GroupPickerScreen(
|
||||
viewModel = groupPickerViewModel,
|
||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||
)
|
||||
return
|
||||
}
|
||||
HomeScreen(
|
||||
scheduleViewModel = scheduleViewModel,
|
||||
teacherScheduleViewModel = teacherScheduleViewModel,
|
||||
updateViewModel = updateViewModel,
|
||||
onSwitchGroup = { scheduleViewModel.switchGroup() },
|
||||
onSwitchTeacher = { teacherScheduleViewModel.switchTeacher() }
|
||||
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,13 +45,5 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
|
||||
}
|
||||
|
||||
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 groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
|
||||
|
||||
@@ -3,24 +3,16 @@ 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,
|
||||
DepartmentEntity::class,
|
||||
DepartmentPeriodEntity::class,
|
||||
TeacherScheduleEntity::class,
|
||||
TeacherDayEntity::class,
|
||||
TeacherLessonEntity::class
|
||||
PreferenceEntity::class
|
||||
],
|
||||
version = 4,
|
||||
version = 2,
|
||||
exportSchema = true
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
@@ -28,91 +20,4 @@ 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`)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,28 +0,0 @@
|
||||
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,13 +25,4 @@ 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,5 +8,9 @@ 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
|
||||
)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -28,45 +28,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,10 +0,0 @@
|
||||
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?
|
||||
)
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,36 +0,0 @@
|
||||
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,7 +3,8 @@ package agzam.vyatsu.data.remote
|
||||
import agzam.vyatsu.domain.model.DateRange
|
||||
import agzam.vyatsu.domain.model.Group
|
||||
import agzam.vyatsu.domain.model.Season
|
||||
import java.io.InputStream
|
||||
import android.util.Log
|
||||
import org.jsoup.Jsoup
|
||||
|
||||
/**
|
||||
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
|
||||
@@ -13,145 +14,67 @@ object GroupsParser {
|
||||
|
||||
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
||||
|
||||
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
||||
|
||||
private const val CHUNK_SIZE = 8192
|
||||
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
||||
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
|
||||
private val shorthands = mapOf(
|
||||
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
|
||||
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
|
||||
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
|
||||
"Факультет лингвистики (ОРУ)" to "ФЛ",
|
||||
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
|
||||
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
|
||||
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
|
||||
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
|
||||
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
|
||||
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
|
||||
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
|
||||
"Электротехнический факультет (ОРУ)" to "ЭТФ",
|
||||
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
|
||||
)
|
||||
|
||||
/**
|
||||
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
||||
* разбирает его на лету, не материализуя весь HTML в память.
|
||||
*
|
||||
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
|
||||
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
|
||||
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
|
||||
* Извлекает список групп с HTML страницы
|
||||
*/
|
||||
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 = ""
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
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()) //
|
||||
|
||||
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),
|
||||
row.select("div.grpPeriod").map { grp ->
|
||||
Group(
|
||||
id = grp.attr("data-grp_period_id").dropLast(1),
|
||||
name = removeParenthesized(grp.text()),
|
||||
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 ParsedGroups(groups, ranges)
|
||||
}
|
||||
|
||||
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
|
||||
private fun findTagEnd(s: CharSequence, start: Int): Int {
|
||||
var i = start
|
||||
var quote = 0.toChar()
|
||||
while (i < s.length) {
|
||||
val c = s[i]
|
||||
if (quote != 0.toChar()) {
|
||||
if (c == quote) quote = 0.toChar()
|
||||
} else {
|
||||
when (c) {
|
||||
'"', '\'' -> quote = c
|
||||
'>' -> return i
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Хвост буфера может содержать неполное начало тега (<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>>>
|
||||
)
|
||||
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)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun shortname(name: String): String {
|
||||
val stripped = removeParenthesized(name)
|
||||
val sh = shorthands[name]
|
||||
return if (sh != null) "$sh ($stripped)" else stripped
|
||||
}
|
||||
|
||||
private fun removeParenthesized(name: String): String = name.replace(Regex("""\(.*?\)\s?"""), "").trim()
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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,7 +5,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -22,7 +21,7 @@ object VyatsuHttpClient {
|
||||
|
||||
suspend fun getText(url: String): String = withContext(Dispatchers.IO) {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
|
||||
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||
resp.body!!.string()
|
||||
}
|
||||
@@ -30,21 +29,10 @@ object VyatsuHttpClient {
|
||||
|
||||
suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
|
||||
Log.d(TAG, "GET $url -> ${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,9 +10,4 @@ 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"
|
||||
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ object PdfScheduleParser {
|
||||
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
||||
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
||||
|
||||
private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
|
||||
private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } )
|
||||
private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
|
||||
|
||||
fun parse(pdfBytes: ByteArray): List<Day> {
|
||||
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
||||
val doc = try {
|
||||
@@ -104,13 +108,9 @@ object PdfScheduleParser {
|
||||
|
||||
for (dayCell in daysColumn.cells) {
|
||||
val day = content(dayCell).split(' ')
|
||||
if(day.size == 1) {
|
||||
Log.v(TAG, "day.first(): ${day.first()}")
|
||||
}
|
||||
if(day.size == 1 && day.first() == "День") {
|
||||
val groupCell = select(lessonsColumn, dayCell).firstOrNull() ?: continue
|
||||
group = content(groupCell)
|
||||
Log.v(TAG, "Group: $group")
|
||||
continue
|
||||
}
|
||||
if(day.size != 2) {
|
||||
@@ -141,7 +141,7 @@ object PdfScheduleParser {
|
||||
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
|
||||
var found = 0
|
||||
var currentStart = 0
|
||||
for (roomMatch in TextPatterns.ROOM_REGEX.findAll(content)) {
|
||||
for (roomMatch in ROOM_REGEX.findAll(content)) {
|
||||
found++
|
||||
|
||||
val raw = content.substring(currentStart, roomMatch.range.last + 1)
|
||||
@@ -151,7 +151,7 @@ object PdfScheduleParser {
|
||||
|
||||
val room = roomMatch.value
|
||||
|
||||
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.findAll(raw).toList()
|
||||
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList()
|
||||
if(typeMatch.isEmpty()) {
|
||||
lessons.add(Lesson(
|
||||
raw=raw,
|
||||
@@ -164,9 +164,9 @@ object PdfScheduleParser {
|
||||
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
|
||||
var subgroup: String? = null
|
||||
Log.d(TAG, "$subject $group")
|
||||
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
|
||||
if(group != null && subject.startsWith("$group,")) {
|
||||
subject = subject.substring(group.length + 1).trim()
|
||||
val subgroupMatch = TextPatterns.SUBGROUP_REGEX.find(subject)
|
||||
val subgroupMatch = SUBGROUP_REGEX.find(subject)
|
||||
if(subgroupMatch != null) {
|
||||
subgroup = subgroupMatch.value.trim()
|
||||
subject = subject.removeRange(subgroupMatch.range).trim()
|
||||
@@ -197,7 +197,6 @@ object PdfScheduleParser {
|
||||
|
||||
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
|
||||
Log.v(SVG_TAG, "====")
|
||||
Log.v(SVG_TAG+ "-" + (pageIndex+1), "====")
|
||||
for(line in svg.asSvg().split("\n")) {
|
||||
if(line.length > 250) {
|
||||
Log.v(SVG_TAG, "length: " + line.length)
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -2,18 +2,16 @@ 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,
|
||||
@@ -42,36 +40,29 @@ class GroupRepositoryImpl(
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
val parsed = withContext(Dispatchers.IO) {
|
||||
VyatsuHttpClient.openStream(VyatsuUrls.GROUPS_PAGE).use(GroupsParser::parseStream)
|
||||
}
|
||||
val now = now()
|
||||
groupDao.insertAll(
|
||||
parsed.groups.map { group ->
|
||||
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,
|
||||
lastSyncedAt = now
|
||||
autumnFirst = autumn?.first?.let(::epochDay),
|
||||
autumnSecond = autumn?.second?.let(::epochDay),
|
||||
springFirst = spring?.first?.let(::epochDay),
|
||||
springSecond = spring?.second?.let(::epochDay),
|
||||
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)
|
||||
groupDao.insertAll(entities)
|
||||
}
|
||||
|
||||
private fun epochDay(dateStr: String): Long = ScheduleDates.asDate(dateStr).toEpochDay()
|
||||
|
||||
private fun now(): Long = System.currentTimeMillis()
|
||||
|
||||
private fun GroupEntity.toDomain(): Group = Group(
|
||||
|
||||
@@ -14,19 +14,6 @@ class PreferencesRepositoryImpl(
|
||||
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 autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() != "false"
|
||||
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,7 +1,6 @@
|
||||
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
|
||||
@@ -52,38 +51,15 @@ class ScheduleRepositoryImpl(
|
||||
Log.w(TAG, "sync: group $groupId not found in DB")
|
||||
return null
|
||||
}
|
||||
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}")
|
||||
val season = ScheduleDates.currentSeason()
|
||||
val range = dateRangeFor(group, season) ?: run {
|
||||
Log.w(TAG, "sync: no date range for season $season (group ${group.id})")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "sync: season=$season range=[${range.first}, ${range.second}]")
|
||||
|
||||
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)
|
||||
return try {
|
||||
val pdfUrl = VyatsuUrls.groupPdfUrl(groupId, season, range)
|
||||
Log.d(TAG, "sync: downloading $pdfUrl")
|
||||
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
|
||||
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
|
||||
@@ -91,19 +67,29 @@ class ScheduleRepositoryImpl(
|
||||
val days = PdfScheduleParser.parse(bytes)
|
||||
Log.d(TAG, "sync: parsed ${days.size} days")
|
||||
|
||||
val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
|
||||
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(
|
||||
ScheduleEntity(
|
||||
groupId = group.id,
|
||||
groupId = groupId,
|
||||
season = season,
|
||||
weekStart = period.weekStart,
|
||||
weekEnd = period.weekEnd,
|
||||
weekStart = weekStart,
|
||||
weekEnd = weekEnd,
|
||||
lastSyncedAt = now()
|
||||
)
|
||||
)
|
||||
replaceDays(scheduleId, days)
|
||||
Log.d(TAG, "sync: period $range synced")
|
||||
|
||||
scheduleDao.trim(groupId, MAX_PERIODS)
|
||||
|
||||
getSchedule(groupId)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
|
||||
Log.e(TAG, "sync: failed for $groupId (${e::class.simpleName}: ${e.message})", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,13 +187,30 @@ 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 = 24
|
||||
private const val MAX_PERIODS = 3
|
||||
private const val TAG = "ScheduleRepo"
|
||||
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
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,13 +6,9 @@ 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
|
||||
@@ -23,7 +19,6 @@ class AppContainer(context: Context) {
|
||||
AppDatabase::class.java,
|
||||
"vyatsu_schedule.db"
|
||||
)
|
||||
.addMigrations(AppDatabase.MIGRATION_3_4)
|
||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||
.build()
|
||||
|
||||
@@ -44,17 +39,6 @@ class AppContainer(context: Context) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
data class BulkSyncProgress(
|
||||
val total: Int,
|
||||
val done: Int,
|
||||
val failed: Int
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
/**
|
||||
* Кафедра ВятГУ.
|
||||
* departmentId — идентификатор кафедры из data-kaf_period_id без последней цифры (как в URL).
|
||||
*/
|
||||
data class Department(
|
||||
val departmentId: String,
|
||||
val name: String,
|
||||
val faculty: String
|
||||
)
|
||||
@@ -26,6 +26,4 @@ data class Lesson(
|
||||
/** Преподаватель **/
|
||||
val teacher: String? = null,
|
||||
|
||||
/** Кафедра-источник (только в расписании преподавателей) **/
|
||||
val department: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
data class ParsedDepartments(
|
||||
val departments: List<Department>,
|
||||
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
package agzam.vyatsu.domain.model
|
||||
|
||||
/**
|
||||
* Преподаватель ВятГУ.
|
||||
* Один и тот же преподаватель может вести занятия в нескольких кафедрах,
|
||||
* поэтому модель не привязана к departmentId — расписание агрегируется
|
||||
* по всем кафедрам периода.
|
||||
*/
|
||||
data class Teacher(
|
||||
val name: String
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -9,17 +9,6 @@ interface PreferencesRepository {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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,35 +1,29 @@
|
||||
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.material3.ExperimentalMaterial3Api
|
||||
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.material.icons.filled.Settings
|
||||
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("Расписание"),
|
||||
@@ -38,48 +32,28 @@ private enum class HomeTab(val label: String) {
|
||||
SETTINGS("Настройки")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
scheduleViewModel: ScheduleViewModel,
|
||||
teacherScheduleViewModel: TeacherScheduleViewModel,
|
||||
updateViewModel: UpdateViewModel,
|
||||
onSwitchGroup: () -> Unit,
|
||||
onSwitchTeacher: () -> Unit
|
||||
onSwitchGroup: () -> 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(
|
||||
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)
|
||||
}
|
||||
imageVector = when (t) {
|
||||
HomeTab.SCHEDULE -> Icons.Default.DateRange
|
||||
HomeTab.CALLS -> Icons.Default.Info
|
||||
HomeTab.BUILDINGS -> Icons.Default.Home
|
||||
HomeTab.SETTINGS -> Icons.Default.Settings
|
||||
},
|
||||
contentDescription = t.label
|
||||
)
|
||||
@@ -90,20 +64,13 @@ fun HomeScreen(
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
androidx.compose.foundation.layout.Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (tab) {
|
||||
HomeTab.SCHEDULE -> if (teacherState.teacherName != null) {
|
||||
TeacherScheduleScreen(
|
||||
viewModel = teacherScheduleViewModel,
|
||||
onSwitchTeacher = onSwitchTeacher
|
||||
)
|
||||
} else {
|
||||
ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||
}
|
||||
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||
HomeTab.CALLS -> CallsScreen()
|
||||
HomeTab.BUILDINGS -> BuildingsScreen()
|
||||
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
|
||||
|
||||
@@ -23,156 +23,7 @@ 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
|
||||
@@ -203,7 +54,17 @@ 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,370 +1,21 @@
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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
|
||||
|
||||
|
||||
|
||||
@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
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
@@ -389,5 +40,3 @@ fun CallsScreen() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -1,737 +0,0 @@
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
}*/
|
||||
@@ -1,3 +0,0 @@
|
||||
package agzam.vyatsu.ui.picker
|
||||
|
||||
enum class PickerSegment { GROUPS, TEACHERS }
|
||||
@@ -1,229 +0,0 @@
|
||||
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,287 +1,65 @@
|
||||
package agzam.vyatsu.ui.schedule
|
||||
|
||||
import agzam.vyatsu.R
|
||||
import agzam.vyatsu.domain.model.Day
|
||||
import agzam.vyatsu.domain.model.LessonType
|
||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||
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 agzam.vyatsu.domain.model.TimeSlot
|
||||
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.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.size
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
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.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.res.painterResource
|
||||
import androidx.compose.ui.draw.clip
|
||||
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)
|
||||
|
||||
val visibleIntervals = day?.intervals
|
||||
?.withIndex()
|
||||
?.filter { (_, slot) ->
|
||||
slot.lessons.any { lesson ->
|
||||
!lesson.subject.isNullOrBlank() ||
|
||||
lesson.raw.isNotBlank()
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
|
||||
if (visibleIntervals.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 = visibleIntervals,
|
||||
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(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("Сменить группу")
|
||||
}
|
||||
@@ -291,9 +69,18 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
},
|
||||
windowInsets = WindowInsets(0.dp)
|
||||
)
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
|
||||
Column(Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 0.dp)) {
|
||||
when {
|
||||
state.schedule == null && state.loadError -> {
|
||||
state.isSyncing && state.schedule == null -> {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
state.schedule == null -> {
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
@@ -308,15 +95,6 @@ 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
|
||||
@@ -328,37 +106,11 @@ 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 ""
|
||||
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = hPadding),
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
@@ -373,33 +125,23 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
onSelect = viewModel::selectWeek
|
||||
)
|
||||
}
|
||||
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
|
||||
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp))
|
||||
|
||||
DayChips(
|
||||
days = weekDays,
|
||||
selectedDate = state.dayDate,
|
||||
todayDate = state.todayDate,
|
||||
onSelect = viewModel::selectDay,
|
||||
pagerState = pagerState
|
||||
onSelect = viewModel::selectDay
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
|
||||
HorizontalDivider(Modifier.padding(top=0.dp, bottom = 8.dp))
|
||||
|
||||
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
|
||||
) {
|
||||
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
itemsIndexed(day.intervals) { slotIndex, slot ->
|
||||
LessonSlot(slotIndex = slotIndex, slot = slot)
|
||||
@@ -412,5 +154,196 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)) {
|
||||
var any = false
|
||||
if(lesson.type != LessonType.OTHER) {
|
||||
Text(
|
||||
lesson.type.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
||||
if(subject.isNotEmpty()) {
|
||||
Text(
|
||||
subject,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
val meta = listOfNotNull(
|
||||
lesson.subgroup,
|
||||
lesson.teacher,
|
||||
lesson.room
|
||||
).filter { it.isNotBlank() }.joinToString(" · ")
|
||||
|
||||
if(meta.isNotEmpty()) {
|
||||
Text(
|
||||
meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
any = true
|
||||
}
|
||||
|
||||
if(!any) {
|
||||
Text(
|
||||
"-",
|
||||
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")
|
||||
|
||||
@@ -37,7 +37,6 @@ class ScheduleViewModel(
|
||||
|
||||
fun selectGroup(groupId: String) {
|
||||
currentGroupId = groupId
|
||||
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setSelectedGroup(groupId)
|
||||
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
|
||||
@@ -62,7 +61,7 @@ class ScheduleViewModel(
|
||||
}
|
||||
|
||||
launch {
|
||||
syncIfMissing(groupId)
|
||||
syncIfMissingToday(groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,9 +96,7 @@ class ScheduleViewModel(
|
||||
schedule = schedule,
|
||||
weekStart = weekStart,
|
||||
dayDate = dayDate,
|
||||
todayDate = todayDate,
|
||||
isLoading = false,
|
||||
loadError = false
|
||||
todayDate = todayDate
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,39 +128,30 @@ class ScheduleViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
fun switchGroup() {
|
||||
viewModelScope.launch {
|
||||
currentGroupId = null
|
||||
preferencesRepository.clearSelectedGroup()
|
||||
_uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
|
||||
_uiState.value = _uiState.value.copy(noGroup = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
val groupId = currentGroupId ?: return
|
||||
viewModelScope.launch {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
||||
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
|
||||
runCatching { groupRepository.forceRefresh() }
|
||||
loadSchedule(groupId)
|
||||
_uiState.value = _uiState.value.copy(isRefreshing = true)
|
||||
scheduleRepository.sync(groupId)
|
||||
_uiState.value = _uiState.value.copy(isRefreshing = false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +175,8 @@ class ScheduleViewModel(
|
||||
|
||||
data class ScheduleUiState(
|
||||
val noGroup: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val loadError: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isSyncing: Boolean = false,
|
||||
val schedule: Schedule? = null,
|
||||
val groupName: String? = null,
|
||||
val weekStart: LocalDate? = null,
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
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.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.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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -1,68 +0,0 @@
|
||||
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
|
||||
// )
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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.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(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.event_busy_24dp),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
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("Повторить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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")
|
||||
@@ -1,96 +0,0 @@
|
||||
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)}"
|
||||
}
|
||||
@@ -1,299 +1,28 @@
|
||||
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()
|
||||
@@ -321,12 +50,12 @@ fun SettingsScreen(viewModel: UpdateViewModel) {
|
||||
item {
|
||||
SettingsItem(
|
||||
title = "Проверить наличие обновлений",
|
||||
subtitle = updateStatusText(updateState, autoEnabled),
|
||||
subtitle = updateStatusText(updateState),
|
||||
onClick = { viewModel.checkNow() }
|
||||
)
|
||||
SettingsDivider()
|
||||
}
|
||||
if (autoEnabled && (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall)) {
|
||||
if (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall) {
|
||||
item {
|
||||
SettingsItem(
|
||||
title = "Установить обновление",
|
||||
@@ -358,10 +87,34 @@ fun SettingsScreen(viewModel: UpdateViewModel) {
|
||||
SettingsDivider()
|
||||
}
|
||||
}
|
||||
}*/
|
||||
}
|
||||
|
||||
private fun updateStatusText(state: UpdateState): String = when (state) {
|
||||
is UpdateState.Idle -> "Проверить сейчас"
|
||||
is UpdateState.Checking -> "Проверка…"
|
||||
is UpdateState.UpToDate -> "Установлена последняя версия"
|
||||
is UpdateState.Available -> "Доступна версия ${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
|
||||
private fun SettingsItem(
|
||||
title: String,
|
||||
@@ -395,5 +148,3 @@ private fun SettingsItem(
|
||||
private fun SettingsDivider() {
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
@@ -17,33 +17,18 @@ class UpdateViewModel(
|
||||
|
||||
val updateState: StateFlow<UpdateState> = updater.state
|
||||
|
||||
private val _autoEnabled = MutableStateFlow(false)
|
||||
private val _autoEnabled = MutableStateFlow(true)
|
||||
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()
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
package agzam.vyatsu.ui.teacher
|
||||
|
||||
import agzam.vyatsu.R
|
||||
import agzam.vyatsu.ui.schedule.components.DayChips
|
||||
import agzam.vyatsu.ui.schedule.components.LessonSlot
|
||||
import agzam.vyatsu.ui.schedule.components.ScheduleDateHeader
|
||||
import agzam.vyatsu.ui.schedule.components.ScheduleError
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
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.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
@@ -1,243 +0,0 @@
|
||||
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()
|
||||
)
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -76,7 +75,6 @@ class AppUpdater(
|
||||
if (job?.isActive == true) return
|
||||
val update = pendingUpdate ?: return
|
||||
job = scope.launch {
|
||||
if (!preferencesRepository.autoUpdateEnabled()) return@launch
|
||||
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
|
||||
}
|
||||
}
|
||||
@@ -87,12 +85,6 @@ class AppUpdater(
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -118,7 +110,7 @@ class AppUpdater(
|
||||
}
|
||||
|
||||
val remoteVersion = release.tagName?.trimStart('v', 'V')
|
||||
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME.trimStart('v', 'V'))) {
|
||||
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME)) {
|
||||
_state.value = UpdateState.UpToDate
|
||||
return null
|
||||
}
|
||||
@@ -174,6 +166,7 @@ class AppUpdater(
|
||||
|
||||
private fun install(apk: File) {
|
||||
if (!canRequestInstalls()) {
|
||||
openUnknownSourcesSettings()
|
||||
return
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(
|
||||
@@ -191,7 +184,6 @@ class AppUpdater(
|
||||
}
|
||||
|
||||
private fun onUpdateFailed(error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
Log.e(TAG, "Ошибка обновления", error)
|
||||
_state.value = UpdateState.Failed(error.message ?: "Неизвестная ошибка")
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,170 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
<vector
|
||||
android:height="108dp"
|
||||
android:width="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||
</vector>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<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>
|
||||
@@ -1,35 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.9 KiB |