Compare commits

..
73 Commits
Author SHA1 Message Date
Agzamus 4f191e9f2f ui: group display istead of teacher in teacher schedule 2026-09-20 15:30:10 +03:00
Agzamus e16454a196 fix(teacher): group day slots by real lesson start time
slotIndex is a per-department index of non-empty intervals, not a time.
Merging all departments of a period by slotIndex and deriving the slot
time from the first inserted lesson produced shifted times (e.g. Meltsov
10:00 shown at 11:45) depending on sync order. Group by lesson start
minute instead so each department's real times are kept.

Add debug logging for teacher schedule sync/build and debounce teacher
search in picker.
2026-09-20 15:13:27 +03:00
Agzamus ef81659521 chore(http): log failed requests only
Per-request GET success logs (39 long URLs per teacher sync) flood the
2MB logcat buffer together with system ZeroHung spam, drowning the real
kill reason (am_proc_died / lowmemorykiller). Log only non-2xx responses.
2026-09-20 03:08:33 +03:00
Agzamus 6189540acf perf(teacher): lower sync memory footprint
- Reduce sync parallelism 5 -> 2 to cap peak heap while bulk-fetching
  all departments for a period
- Replace per-write rows (insert schedule/days/lessons separately) with a
  single @Transaction replaceSchedule() so Room invalidates observers once
  per department instead of ~4x each, cutting main-thread recompositions
  and intermediate allocations during the 38-department sync
2026-09-20 02:43:54 +03:00
Agzamus 1d55e84e96 fix(teacher): register migration, containment period queries, concurrency guards
- Register AppDatabase.MIGRATION_3_4 so v3 databases upgrade without data loss
- Query teacher schedule periods by containment (weekStart<=x AND weekEnd>=x)
  instead of exact weekStart, matching 2-week catalog windows
- Clip buildTeacherSchedule days to the requested week range
- Add weeksFor() to enumerate weeks of a catalog window for the UI selector
- Serialize syncPeriod body with a Mutex to protect the unique index
- Rethrow CancellationException before generic catch; version loadRevision
  checks so stale observers/loads are cancelled
- Sync job tracking in PickerViewModel; keep loading true while empty
- Drop dead selectTeacher helper
2026-09-19 23:32:09 +03:00
Agzamus 39e4f5a04f fix(remote): replace unsupported (?U)\b regex for Android runtime 2026-09-19 20:44:22 +03:00
Agzamus e1b2f05548 feat(ui): integrate teacher mode into AppRoot and Home 2026-09-19 20:28:13 +03:00
Agzamus f5696fbbd6 feat(ui): add combined group/teacher picker with segments 2026-09-19 20:16:41 +03:00
Agzamus b9b3a18f0c feat(di): wire teacher repositories into AppContainer 2026-09-19 19:37:56 +03:00
Agzamus 2dd8b8c469 feat(ui): add teacher schedule screen and view model 2026-09-19 19:12:19 +03:00
Agzamus 6c06892d9a feat(data): add TeacherScheduleRepositoryImpl with bulk period sync 2026-09-19 19:12:19 +03:00
Agzamus 9dce30931b feat(data): add TeacherRepositoryImpl 2026-09-19 15:59:25 +03:00
Agzamus 32c2b60afa feat(remote): add jsoup TeacherScheduleParser 2026-09-19 14:47:55 +03:00
Agzamus 458e4c4e69 feat(remote): add streaming TeachersParser 2026-09-19 14:47:46 +03:00
Agzamus 1a4bff73d0 feat(db): add teacher schedule tables and Room v4 migration 2026-09-19 14:47:35 +03:00
Agzamus a5ea516713 refactor(ui): extract shared schedule composables 2026-09-19 01:47:29 +03:00
Agzamus 17d437aace feat(domain): add teacher models and repository interfaces 2026-09-19 01:43:17 +03:00
vantablackTea 1077be1897 fix: possible moshi deleting due to proguard 2026-09-18 11:36:25 +03:00
vantablackTea 96db4fa3a6 fix: reverting separate release versions by architecture 2026-09-18 10:01:02 +03:00
vantablackTea 830b4664fb fix: added proguard rules 2026-09-18 09:48:14 +03:00
vantablackTea dd4fb90dc4 fix: testing build optimisation 2026-09-18 09:30:19 +03:00
Agzamus a55fb0461c ci: debug build name 2026-09-18 02:30:56 +03:00
Agzamus e789729c76 ui: removed version info from buildings screen 2026-09-18 02:16:46 +03:00
Agzamus 7e23895f0d fix: version check 2026-09-18 02:16:29 +03:00
Agzamus 708ad11dca ci: release same tag & version 2026-09-18 01:28:48 +03:00
Agzamus b1418f79da fix: immediately scrolling 2026-09-18 01:25:00 +03:00
Agzamus d17c70e871 feat: schedule swiping 2026-09-18 01:20:47 +03:00
Agzamus 6cd211f744 fix: no more group prefix 2026-09-11 02:34:30 +03:00
Agzamus 019cf5da21 feat: also refresh groups list in schedule 2026-09-11 02:05:43 +03:00
Agzamus a24748ec04 ci: "debug" if no APP_VERSION 2026-09-11 02:04:36 +03:00
Agzamus caf3debb3f feat: 2-week select 2026-09-11 01:55:52 +03:00
Agzamus 1c5140d751 fix: streaming parsing to prevent crashes on low-end phones 2026-09-11 01:34:05 +03:00
Agzamus 65bb6cc7db fix: loading instead error screen 2026-09-11 00:18:23 +03:00
Agzamus 99e05e8232 ui: surfaceContainer background 2026-09-10 01:36:24 +03:00
Agzamus c4ecc43597 ui: canged colors 2026-09-10 01:01:57 +03:00
Agzamus 9a96bd3dac ci: installing from /github.com/adoptium 2026-09-10 00:47:38 +03:00
Agzamus c4345f9dbe ci: bullseye repo archive mirror 2026-09-10 00:44:18 +03:00
Agzamus 4797811097 ci: ignore jdk Check-Valid-Until 2026-09-10 00:31:17 +03:00
Agzamus 2dfef426a7 ui: schedule striped copyable rows 2026-09-10 00:23:28 +03:00
Agzamus 97c19647cc ui: LessonSlot separator 2026-09-09 22:25:55 +03:00
Agzamus dea27bc8f1 ci: fixed workflow old apt jdk17 repo 2026-09-07 00:35:50 +03:00
Agzamus bafebfd253 fix: no auto download if updated disabled 2026-09-07 00:31:34 +03:00
Agzamus f19d65abb5 feat: update request when need 2026-09-06 17:23:55 +03:00
Agzamus 889c57bbba feat: autoupdater 2026-09-06 12:38:00 +03:00
Agzamus ec6a7174e8 feat: icon & name 2026-09-06 02:47:40 +03:00
Agzamus 014896aaea ui: no more empty lines in schedule 2026-09-05 00:21:39 +03:00
Agzamus 9f6cf3ef30 fix: #1 - text blocks by words 2026-09-05 00:13:38 +03:00
Agzamus d1923d3296 ci: auto version_name 2026-09-04 04:43:43 +03:00
Agzamus 686976347e feat: better parsing & display 2026-09-04 04:40:29 +03:00
Agzamus 699bcd81f5 ui: header group display 2026-09-04 02:05:52 +03:00
Agzamus 050f835a33 ui: reduced maket padding 2026-09-04 01:22:35 +03:00
Agzamus 5fee57d93a ui: better week select l&f 2026-09-04 01:07:09 +03:00
Agzamus 36711d1bf3 ui: smaller week select 2026-09-03 23:34:45 +03:00
Agzamus c2a9cfb7e7 refactor: weeks select -> combobox 2026-09-03 23:16:57 +03:00
Agzamus 467c1a735f fix: Key "XXX" was already used. 2026-09-03 23:01:05 +03:00
Agzamus 28965e6aed Merge branch 'database-refactor' 2026-09-03 22:54:49 +03:00
Agzamus 181f8b2f2a Merge branch 'database-refactor' 2026-09-03 22:53:26 +03:00
Agzamus 1fc43bb8fc refactor: clenup 2026-09-03 20:54:44 +03:00
Agzamus 32f93197ab feat: week select 2026-09-03 20:53:17 +03:00
Agzamus 32d0cec8ce feat: removed auto sync 2026-09-03 20:46:11 +03:00
Agzamus 72e63addb2 fix: FOREIGN KEY constraint failed 2026-09-03 20:41:03 +03:00
Agzamus 84ae97b906 feat: group display on schedule screen 2026-09-03 19:59:21 +03:00
Agzamus 2069ec811a ci: removed "Upload APK as artifact" action step 2026-09-03 19:47:06 +03:00
Agzamus ebe1c3c4a3 refactor: database multi-period schedule storage 2026-09-03 19:41:26 +03:00
Agzamus 47ca253d6a ci: use gitea-release-action 2026-09-03 01:52:41 +03:00
Agzamus 766b37321d ci: install correct Android SDK 36.1 in CI 2026-09-03 01:21:46 +03:00
Agzamus 83a3ee68a8 ci: added JAVA_HOME 2026-09-03 01:12:34 +03:00
Agzamus 0c8eb783a5 ci: added upload step 2026-09-03 00:57:26 +03:00
Agzamus 91ea47f63a ci: base64 key generating 2026-09-03 00:39:40 +03:00
Agzamus 8f4ab2783e ci: autorelease 2026-09-03 00:06:07 +03:00
Agzamus 72838d3448 chore: add .idea to gitignore 2026-09-02 23:52:55 +03:00
Agzamus fdb8d712e8 init 2026-09-02 23:47:26 +03:00
Agzamus a8830bcd91 init 2026-09-02 23:47:00 +03:00
128 changed files with 26757 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
# Требуемые секреты репозитория (Settings → Actions → Secrets):
# ANDROID_KEYSTORE — содержимое .jks файла (base64-кодированное)
# ANDROID_KEYSTORE_PASSWORD
# ANDROID_KEY_ALIAS
# ANDROID_KEY_PASSWORD
name: Release APK
on:
workflow_dispatch:
inputs:
release_notes:
description: 'Текст описания релиза (Markdown), можно оставить пустым'
required: false
default: ''
type: string
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
releases: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 17
# Ставим JDK вручную и явно прописываем JAVA_HOME.
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
run: |
set -euxo pipefail
cd /tmp
wget -q https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.12%2B7/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz
tar -xzf OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz -C /opt
mv /opt/jdk-17.0.12+7 /opt/java17
export JAVA_HOME=/opt/java17
export PATH=$JAVA_HOME/bin:$PATH
echo "JAVA_HOME=/opt/java17" >> $GITHUB_ENV
echo "$JAVA_HOME/bin" >> $GITHUB_PATH
java -version
javac -version
- 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
id: version
run: |
BASE="$(date +%Y.%m.%d)"
TAG="v$BASE"
SUFFIX=0
while git ls-remote --tags origin "$TAG" 2>/dev/null | grep -q "$TAG"; do
SUFFIX=$((SUFFIX + 1))
TAG="v${BASE}.${SUFFIX}"
done
echo "version_name=$TAG" >> "$GITHUB_OUTPUT"
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Generated version: $BASE"
- name: Restore keystore from secret
# Ключ подписи передаётся как base64-секрет и декодируется в файл.
run: |
if [ -n "$ANDROID_KEYSTORE" ]; then
echo "$ANDROID_KEYSTORE" | base64 -d > keystore.jks
echo "keystore restored"
else
echo "::warning::ANDROID_KEYSTORE not set, building unsigned release"
fi
env:
ANDROID_KEYSTORE: ${{ secrets.ANDROID_KEYSTORE }}
- name: Build release APK
run: ./gradlew assembleRelease
env:
# Версия приложения — дата, сгенерированная на предыдущем шаге.
# Читается в app/build.gradle.kts как versionName (для BuildConfig).
APP_VERSION: ${{ steps.version.outputs.version_name }}
# Переменные окружения, которые читает signingConfig в app/build.gradle.kts
ANDROID_KEYSTORE_PATH: ${{ github.workspace }}/keystore.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ORG_GRADLE_JVMARGS: '-Xmx2048m'
GRADLE_OPTS: '-Dorg.gradle.daemon=false'
- name: Determine APK path
id: apk
run: |
APK=$(find app/build/outputs/apk/release -name "*.apk" ! -name "*unsigned*" ! -name "*unaligned*" | head -n 1)
if [ -z "$APK" ]; then
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -n 1)
fi
echo "apk_path=$APK" >> "$GITHUB_OUTPUT"
echo "APK path: $APK"
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.version.outputs.version_tag }}
name: ${{ steps.version.outputs.version_name }}
body: ${{ github.event.inputs.release_notes }}
files: ${{ steps.apk.outputs.apk_path }}
token: ${{ secrets.GITEA_TOKEN }}
server_url: ${{ gitea.server_url }}
+7
View File
@@ -17,3 +17,10 @@ local.properties
/app/build /app/build
/app/release /app/release
/.idea
# Keystore подписи и его base64-представление для Gitea-секрета
*.jks
*.keystore
/scripts/.keystore.b64
.keystore.b64
+120
View File
@@ -0,0 +1,120 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.ksp)
}
android {
namespace = "agzam.vyatsu"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "agzam.vyatsu"
minSdk = 28
targetSdk = 36
versionCode = 1
// В продакшн-сборке версию приложения подставляет CI (env APP_VERSION = дата ГГГГ.ММ.ДД).
// Локально и при отсутствии переменной — fallback "1.0".
versionName = System.getenv("APP_VERSION") ?: "debug"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
// Релизная подпись, ключ и пароли читаются из переменных окружения
// release-сборка не подписывается
signingConfigs {
create("release") {
val storePath = System.getenv("ANDROID_KEYSTORE_PATH")
if (!storePath.isNullOrBlank()) {
storeFile = file(storePath)
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// Подпись (если есть)
if (!System.getenv("ANDROID_KEYSTORE_PATH").isNullOrBlank()) {
signingConfig = signingConfigs.getByName("release")
}
}
}
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all { it.maxHeapSize = "16m" }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
jvmToolchain(11)
}
buildFeatures {
compose = true
buildConfig = true
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.kotlinx.coroutines.android)
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
implementation(libs.androidx.monitor)
ksp(libs.androidx.room.compiler)
// Networking
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
// HTML
implementation(libs.jsoup)
// JSON
implementation(libs.moshi)
implementation(libs.moshi.kotlin)
ksp(libs.moshi.kotlin.codegen)
// PDF
implementation(libs.pdfbox.android)
testImplementation(libs.junit)
testImplementation("org.apache.pdfbox:pdfbox:3.0.4")
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
+40
View File
@@ -0,0 +1,40 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Игнорировать отсутствие класса декодера JP2 в PdfBox-Android
-dontwarn com.gemalto.jp2.JP2Decoder
# На всякий случай, если PdfBox ссылается на другие отсутствующие библиотеки (например, ICU для текста)
-dontwarn com.tom_roush.pdfbox.**
# Запретить R8 удалять и переименовывать внутренности PdfBox-Android
-keep class com.tom_roush.pdfbox.** { *; }
-keep interface com.tom_roush.pdfbox.** { *; }
# Moshi удаляется proguard
-keepnames @com.squareup.moshi.JsonClass class *
-keep class **JsonAdapter {
public <init>(...);
<fields>;
}
-keepclassmembers @com.squareup.moshi.JsonClass class * {
synthetic <init>(...);
}
@@ -0,0 +1,381 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "48411ce0f1f22ef1dccd5852a9e97c5d",
"entities": [
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `autumnFirst` INTEGER, `autumnSecond` INTEGER, `springFirst` INTEGER, `springSecond` INTEGER, `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": "autumnFirst",
"columnName": "autumnFirst",
"affinity": "INTEGER"
},
{
"fieldPath": "autumnSecond",
"columnName": "autumnSecond",
"affinity": "INTEGER"
},
{
"fieldPath": "springFirst",
"columnName": "springFirst",
"affinity": "INTEGER"
},
{
"fieldPath": "springSecond",
"columnName": "springSecond",
"affinity": "INTEGER"
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"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, '48411ce0f1f22ef1dccd5852a9e97c5d')"
]
}
}
@@ -0,0 +1,427 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "3fd5eda9ac5e29fc96371ecf246e437c",
"entities": [
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "faculty",
"columnName": "faculty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "group_periods",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_group_periods_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "schedules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_schedules_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
},
{
"name": "index_schedules_groupId_season_weekStart",
"unique": true,
"columnNames": [
"groupId",
"season",
"weekStart"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "days",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleId",
"columnName": "scheduleId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_days_scheduleId",
"unique": false,
"columnNames": [
"scheduleId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
},
{
"name": "index_days_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
}
],
"foreignKeys": [
{
"table": "schedules",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"scheduleId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "lessons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayId",
"columnName": "dayId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slotIndex",
"columnName": "slotIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lessonIndex",
"columnName": "lessonIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT"
},
{
"fieldPath": "groupName",
"columnName": "groupName",
"affinity": "TEXT"
},
{
"fieldPath": "subgroup",
"columnName": "subgroup",
"affinity": "TEXT"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_lessons_dayId",
"unique": false,
"columnNames": [
"dayId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
},
{
"name": "index_lessons_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
},
{
"name": "index_lessons_subject",
"unique": false,
"columnNames": [
"subject"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
},
{
"name": "index_lessons_room",
"unique": false,
"columnNames": [
"room"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
}
],
"foreignKeys": [
{
"table": "days",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"dayId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "preferences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3fd5eda9ac5e29fc96371ecf246e437c')"
]
}
}
@@ -0,0 +1,818 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "38abab3f8c5b39625b6a4de9d58968ca",
"entities": [
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "faculty",
"columnName": "faculty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "group_periods",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_group_periods_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "schedules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_schedules_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
},
{
"name": "index_schedules_groupId_season_weekStart",
"unique": true,
"columnNames": [
"groupId",
"season",
"weekStart"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "days",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleId",
"columnName": "scheduleId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_days_scheduleId",
"unique": false,
"columnNames": [
"scheduleId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
},
{
"name": "index_days_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
}
],
"foreignKeys": [
{
"table": "schedules",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"scheduleId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "lessons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayId",
"columnName": "dayId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slotIndex",
"columnName": "slotIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lessonIndex",
"columnName": "lessonIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT"
},
{
"fieldPath": "groupName",
"columnName": "groupName",
"affinity": "TEXT"
},
{
"fieldPath": "subgroup",
"columnName": "subgroup",
"affinity": "TEXT"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_lessons_dayId",
"unique": false,
"columnNames": [
"dayId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
},
{
"name": "index_lessons_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
},
{
"name": "index_lessons_subject",
"unique": false,
"columnNames": [
"subject"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
},
{
"name": "index_lessons_room",
"unique": false,
"columnNames": [
"room"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
}
],
"foreignKeys": [
{
"table": "days",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"dayId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "preferences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
},
{
"tableName": "departments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`departmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`departmentId`))",
"fields": [
{
"fieldPath": "departmentId",
"columnName": "departmentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "faculty",
"columnName": "faculty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"departmentId"
]
}
},
{
"tableName": "department_periods",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "departmentId",
"columnName": "departmentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_department_periods_departmentId",
"unique": false,
"columnNames": [
"departmentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_department_periods_departmentId` ON `${TABLE_NAME}` (`departmentId`)"
}
],
"foreignKeys": [
{
"table": "departments",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"departmentId"
],
"referencedColumns": [
"departmentId"
]
}
]
},
{
"tableName": "teacher_schedules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "departmentId",
"columnName": "departmentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_teacher_schedules_departmentId",
"unique": false,
"columnNames": [
"departmentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId` ON `${TABLE_NAME}` (`departmentId`)"
},
{
"name": "index_teacher_schedules_departmentId_season_weekStart",
"unique": true,
"columnNames": [
"departmentId",
"season",
"weekStart"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId_season_weekStart` ON `${TABLE_NAME}` (`departmentId`, `season`, `weekStart`)"
}
],
"foreignKeys": [
{
"table": "departments",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"departmentId"
],
"referencedColumns": [
"departmentId"
]
}
]
},
{
"tableName": "teacher_days",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `teacher_schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleId",
"columnName": "scheduleId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_teacher_days_scheduleId",
"unique": false,
"columnNames": [
"scheduleId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
},
{
"name": "index_teacher_days_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_days_date` ON `${TABLE_NAME}` (`date`)"
}
],
"foreignKeys": [
{
"table": "teacher_schedules",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"scheduleId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "teacher_lessons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT NOT NULL, FOREIGN KEY(`dayId`) REFERENCES `teacher_days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayId",
"columnName": "dayId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slotIndex",
"columnName": "slotIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lessonIndex",
"columnName": "lessonIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT"
},
{
"fieldPath": "groupName",
"columnName": "groupName",
"affinity": "TEXT"
},
{
"fieldPath": "subgroup",
"columnName": "subgroup",
"affinity": "TEXT"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_teacher_lessons_dayId",
"unique": false,
"columnNames": [
"dayId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
},
{
"name": "index_teacher_lessons_teacher",
"unique": false,
"columnNames": [
"teacher"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_teacher` ON `${TABLE_NAME}` (`teacher`)"
},
{
"name": "index_teacher_lessons_room",
"unique": false,
"columnNames": [
"room"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_room` ON `${TABLE_NAME}` (`room`)"
},
{
"name": "index_teacher_lessons_subject",
"unique": false,
"columnNames": [
"subject"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
}
],
"foreignKeys": [
{
"table": "teacher_days",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"dayId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '38abab3f8c5b39625b6a4de9d58968ca')"
]
}
}
@@ -0,0 +1,24 @@
package agzam.vyatsu
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("agzam.vyatsu", appContext.packageName)
}
}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".VyatsuApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/icon"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.VyatsuSchedule">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.VyatsuSchedule">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
+90
View File
@@ -0,0 +1,90 @@
package agzam.vyatsu
import agzam.vyatsu.di.AppContainer
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
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun AppRoot() {
val app = LocalContext.current.applicationContext as VyatsuApplication
val container = app.container
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
val teacherScheduleViewModel: TeacherScheduleViewModel = viewModel(factory = teacherScheduleFactory(container))
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
val state by scheduleViewModel.uiState.collectAsState()
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
if (showFirstRunDialog) {
AlertDialog(
onDismissRequest = { updateViewModel.onFirstRunDecision(false) },
title = { Text("Автоматические обновления") },
text = {
Text(
"Разрешить автоматически проверять и устанавливать " +
"новые версии приложения?"
)
},
confirmButton = {
TextButton(onClick = { updateViewModel.onFirstRunDecision(true) }) {
Text("Разрешить")
}
},
dismissButton = {
TextButton(onClick = { updateViewModel.onFirstRunDecision(false) }) {
Text("Не надо")
}
}
)
}
if (state.noGroup && teacherState.noTeacher) {
val pickerViewModel: PickerViewModel = viewModel(factory = pickerFactory(container))
PickerScreen(
viewModel = pickerViewModel,
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) },
onTeacherPicked = { teacher -> teacherScheduleViewModel.selectTeacher(teacher.name) }
)
return
}
HomeScreen(
scheduleViewModel = scheduleViewModel,
teacherScheduleViewModel = teacherScheduleViewModel,
updateViewModel = updateViewModel,
onSwitchGroup = { scheduleViewModel.switchGroup() },
onSwitchTeacher = { teacherScheduleViewModel.switchTeacher() }
)
}
private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
}
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
private fun teacherScheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { TeacherScheduleViewModel(c.teacherScheduleRepository, c.preferencesRepository) }
private fun pickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory {
PickerViewModel(
c.groupRepository,
c.teacherRepository,
c.teacherScheduleRepository,
c.preferencesRepository
)
}
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
@@ -0,0 +1,21 @@
package agzam.vyatsu
import agzam.vyatsu.ui.theme.VyatsuScheduleTheme
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val container = (application as VyatsuApplication).container
container.appUpdater.autoCheckAndInstall()
setContent {
VyatsuScheduleTheme {
AppRoot()
}
}
}
}
@@ -0,0 +1,16 @@
package agzam.vyatsu
import agzam.vyatsu.di.AppContainer
import android.app.Application
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
class VyatsuApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
PDFBoxResourceLoader.init(this)
container = AppContainer(this)
}
}
@@ -0,0 +1,118 @@
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
],
version = 4,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun groupDao(): GroupDao
abstract fun scheduleDao(): ScheduleDao
abstract fun preferencesDao(): PreferencesDao
abstract fun teacherDao(): TeacherDao
abstract fun teacherScheduleDao(): TeacherScheduleDao
companion object {
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `departments` (" +
"`departmentId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
"`faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, " +
"PRIMARY KEY(`departmentId`))"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `department_periods` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, " +
"`weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, " +
"FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_department_periods_departmentId` " +
"ON `department_periods` (`departmentId`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `teacher_schedules` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, " +
"`weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, " +
"`lastSyncedAt` INTEGER NOT NULL, " +
"FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId` " +
"ON `teacher_schedules` (`departmentId`)"
)
db.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId_season_weekStart` " +
"ON `teacher_schedules` (`departmentId`, `season`, `weekStart`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `teacher_days` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, " +
"FOREIGN KEY(`scheduleId`) REFERENCES `teacher_schedules`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_days_scheduleId` " +
"ON `teacher_days` (`scheduleId`)"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_days_date` " +
"ON `teacher_days` (`date`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `teacher_lessons` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, " +
"`slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, " +
"`end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, " +
"`raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, " +
"`subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, " +
"`teacher` TEXT NOT NULL, " +
"FOREIGN KEY(`dayId`) REFERENCES `teacher_days`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_dayId` " +
"ON `teacher_lessons` (`dayId`)"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_teacher` " +
"ON `teacher_lessons` (`teacher`)"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_room` " +
"ON `teacher_lessons` (`room`)"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_subject` " +
"ON `teacher_lessons` (`subject`)"
)
}
}
}
}
@@ -0,0 +1,29 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.TypeConverter
import java.time.LocalDate
import java.time.LocalTime
class Converters {
@TypeConverter
fun localDateToEpochDay(date: LocalDate?): Long? = date?.toEpochDay()
@TypeConverter
fun epochDayToLocalDate(epochDay: Long?): LocalDate? = epochDay?.let(LocalDate::ofEpochDay)
@TypeConverter
fun localTimeToMinutes(time: LocalTime?): Int? = time?.let { it.hour * 60 + it.minute }
@TypeConverter
fun minutesToLocalTime(minutes: Int?): LocalTime? =
minutes?.let { LocalTime.of(it / 60, it % 60) }
@TypeConverter
fun lessonTypeToString(type: LessonType): String = type.name
@TypeConverter
fun stringToLessonType(value: String): LessonType =
runCatching { LessonType.valueOf(value) }.getOrDefault(LessonType.OTHER)
}
@@ -0,0 +1,27 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "days",
foreignKeys = [
ForeignKey(
entity = ScheduleEntity::class,
parentColumns = ["id"],
childColumns = ["scheduleId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("scheduleId"),
Index("date")
]
)
data class DayEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val scheduleId: Long,
val date: Long
)
@@ -0,0 +1,12 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "departments")
data class DepartmentEntity(
@PrimaryKey val departmentId: String,
val name: String,
val faculty: String,
val lastSyncedAt: Long
)
@@ -0,0 +1,28 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
// Все опубликованные на сайте 2-недельные периоды кафедры, включая ещё не начавшиеся.
@Entity(
tableName = "department_periods",
foreignKeys = [
ForeignKey(
entity = DepartmentEntity::class,
parentColumns = ["departmentId"],
childColumns = ["departmentId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("departmentId")]
)
data class DepartmentPeriodEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val departmentId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long
)
@@ -0,0 +1,37 @@
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 GroupDao {
@Query("SELECT * FROM groups ORDER BY name")
fun observeAll(): Flow<List<GroupEntity>>
@Query("SELECT * FROM groups WHERE id = :id")
suspend fun getById(id: String): GroupEntity?
@Query("SELECT * FROM groups WHERE faculty = :faculty ORDER BY name")
suspend fun getByFaculty(faculty: String): List<GroupEntity>
@Query("SELECT DISTINCT faculty FROM groups ORDER BY faculty")
suspend fun getFaculties(): List<String>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(groups: List<GroupEntity>)
@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>)
}
@@ -0,0 +1,12 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "groups")
data class GroupEntity(
@PrimaryKey val id: String,
val name: String,
val faculty: String,
val lastSyncedAt: Long
)
@@ -0,0 +1,28 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
// Все опубликованные на сайте 2-недельные периоды группы, включая ещё не начавшиеся.
@Entity(
tableName = "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
)
@@ -0,0 +1,41 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "lessons",
foreignKeys = [
ForeignKey(
entity = DayEntity::class,
parentColumns = ["id"],
childColumns = ["dayId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("dayId"),
Index("date"),
Index("subject"),
Index("room")
]
)
data class LessonEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val dayId: Long,
val date: Long,
val slotIndex: Int,
val start: Int,
val end: Int,
val lessonIndex: Int,
val raw: String,
val room: String?,
val groupName: String?,
val subgroup: String?,
val subject: String?,
val type: LessonType,
val teacher: String?
)
@@ -0,0 +1,10 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "preferences")
data class PreferenceEntity(
@PrimaryKey val key: String,
val value: String
)
@@ -0,0 +1,72 @@
package agzam.vyatsu.data.local
import androidx.room.Dao
import androidx.room.Query
@Dao
interface PreferencesDao {
@Query("SELECT value FROM preferences WHERE key = 'selectedGroupId'")
suspend fun selectedGroupId(): String?
@Query(
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedGroupId', :groupId)"
)
suspend fun setSelectedGroup(groupId: String)
@Query("DELETE FROM preferences WHERE key = 'selectedGroupId'")
suspend fun clearSelectedGroup()
@Query("SELECT value FROM preferences WHERE key = 'selectedWeekStart'")
suspend fun selectedWeekStart(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedWeekStart', :weekStart)")
suspend fun setSelectedWeekStart(weekStart: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedDayDate'")
suspend fun selectedDayDate(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
suspend fun setSelectedDayDate(date: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherId'")
suspend fun selectedTeacherId(): String?
@Query(
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherId', :teacherId)"
)
suspend fun setSelectedTeacherId(teacherId: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherWeekStart'")
suspend fun selectedTeacherWeekStart(): String?
@Query(
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherWeekStart', :weekStart)"
)
suspend fun setSelectedTeacherWeekStart(weekStart: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherDayDate'")
suspend fun selectedTeacherDayDate(): String?
@Query(
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherDayDate', :date)"
)
suspend fun setSelectedTeacherDayDate(date: String)
@Query(
"DELETE FROM preferences WHERE key IN " +
"('selectedTeacherId', 'selectedTeacherWeekStart', 'selectedTeacherDayDate')"
)
suspend fun clearSelectedTeacher()
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateEnabled'")
suspend fun autoUpdateEnabled(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateEnabled', :enabled)")
suspend fun setAutoUpdateEnabled(enabled: String)
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateAsked'")
suspend fun autoUpdateAsked(): String?
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('autoUpdateAsked', :asked)")
suspend fun setAutoUpdateAsked(asked: String)
}
@@ -0,0 +1,75 @@
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 ScheduleDao {
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
fun observeSchedules(groupId: String): Flow<List<ScheduleEntity>>
@Query("SELECT * FROM schedules WHERE groupId = :groupId ORDER BY weekStart DESC")
suspend fun getSchedules(groupId: String): List<ScheduleEntity>
@Query("DELETE FROM days WHERE scheduleId = :scheduleId")
suspend fun deleteDaysForSchedule(scheduleId: Long)
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertSchedule(schedule: ScheduleEntity): Long
@Query("DELETE FROM schedules WHERE groupId = :groupId AND season = :season AND weekStart = :weekStart")
suspend fun deleteSchedule(groupId: String, season: String, weekStart: Long)
@Query("DELETE FROM schedules WHERE groupId = :groupId")
suspend fun deleteAll(groupId: String)
@Query(
"""
DELETE FROM schedules
WHERE groupId = :groupId
AND id NOT IN (
SELECT id FROM schedules
WHERE groupId = :groupId
ORDER BY weekStart DESC
LIMIT :keep
)
"""
)
suspend fun trim(groupId: String, keep: Int)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDays(days: List<DayEntity>): List<Long>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLessons(lessons: List<LessonEntity>)
@Query(
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY days.date"
)
suspend fun getDaysForGroup(groupId: String): List<DayEntity>
@Query(
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
)
suspend fun getLessonsForGroup(groupId: String): List<LessonEntity>
@Query(
"SELECT days.* FROM days INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY days.date"
)
fun observeDaysForGroup(groupId: String): Flow<List<DayEntity>>
@Query(
"SELECT lessons.* FROM lessons INNER JOIN days ON days.id = lessons.dayId " +
"INNER JOIN schedules ON schedules.id = days.scheduleId " +
"WHERE schedules.groupId = :groupId ORDER BY lessons.date, lessons.slotIndex, lessons.lessonIndex"
)
fun observeLessonsForGroup(groupId: String): Flow<List<LessonEntity>>
}
@@ -0,0 +1,31 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "schedules",
foreignKeys = [
ForeignKey(
entity = GroupEntity::class,
parentColumns = ["id"],
childColumns = ["groupId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("groupId"),
Index(value = ["groupId", "season", "weekStart"], unique = true)
]
)
data class ScheduleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val groupId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long,
val lastSyncedAt: Long
)
@@ -0,0 +1,39 @@
package agzam.vyatsu.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface TeacherDao {
@Query("SELECT * FROM departments ORDER BY name")
fun observeDepartments(): Flow<List<DepartmentEntity>>
@Query("SELECT * FROM departments WHERE departmentId = :id")
suspend fun getDepartment(id: String): DepartmentEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(departments: List<DepartmentEntity>)
@Query("SELECT MAX(lastSyncedAt) FROM departments")
suspend fun maxSyncedAt(): Long?
@Query("SELECT DISTINCT faculty FROM departments ORDER BY faculty")
suspend fun getFaculties(): List<String>
@Query("DELETE FROM department_periods")
suspend fun deleteAllPeriods()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPeriods(periods: List<DepartmentPeriodEntity>)
@Query(
"SELECT * FROM department_periods WHERE weekStart <= :epochDay AND weekEnd >= :epochDay ORDER BY departmentId"
)
suspend fun getPeriodsForDate(epochDay: Long): List<DepartmentPeriodEntity>
@Query("SELECT * FROM department_periods WHERE departmentId = :departmentId ORDER BY weekStart")
suspend fun getPeriodsForDepartment(departmentId: String): List<DepartmentPeriodEntity>
}
@@ -0,0 +1,27 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_days",
foreignKeys = [
ForeignKey(
entity = TeacherScheduleEntity::class,
parentColumns = ["id"],
childColumns = ["scheduleId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("scheduleId"),
Index("date")
]
)
data class TeacherDayEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val scheduleId: Long,
val date: Long
)
@@ -0,0 +1,41 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_lessons",
foreignKeys = [
ForeignKey(
entity = TeacherDayEntity::class,
parentColumns = ["id"],
childColumns = ["dayId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("dayId"),
Index("teacher"),
Index("room"),
Index("subject")
]
)
data class TeacherLessonEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val dayId: Long,
val date: Long,
val slotIndex: Int,
val start: Int,
val end: Int,
val lessonIndex: Int,
val raw: String,
val room: String?,
val groupName: String?,
val subgroup: String?,
val subject: String?,
val type: LessonType,
val teacher: String
)
@@ -0,0 +1,81 @@
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()
@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>) {
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.* 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_lessons.date, teacher_lessons.slotIndex, teacher_lessons.lessonIndex"
)
fun observeLessonsForPeriod(season: String, weekStart: Long): Flow<List<TeacherLessonEntity>>
@Query(
"SELECT DISTINCT teacher FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher"
)
fun observeDistinctTeachers(season: String, weekStart: Long): Flow<List<String>>
@Query(
"SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season " +
"AND weekStart <= :weekStart AND weekEnd >= :weekStart)"
)
suspend fun hasSchedulesForPeriod(season: String, weekStart: Long): Boolean
}
@@ -0,0 +1,31 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_schedules",
foreignKeys = [
ForeignKey(
entity = DepartmentEntity::class,
parentColumns = ["departmentId"],
childColumns = ["departmentId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("departmentId"),
Index(value = ["departmentId", "season", "weekStart"], unique = true)
]
)
data class TeacherScheduleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val departmentId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long,
val lastSyncedAt: Long
)
@@ -0,0 +1,36 @@
package agzam.vyatsu.data.remote
/**
* Справочник сокращённых названий факультетов ВятГУ.
* Вынесен из GroupsParser, так как TeachersParser использует те же сокращения.
*/
object FacultyNames {
private val parenthesized = Regex("""\(.*?\)\s?""")
private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
"Факультет лингвистики (ОРУ)" to "ФЛ",
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
"Электротехнический факультет (ОРУ)" to "ЭТФ",
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
)
fun shortname(name: String): String {
val stripped = removeParenthesized(name)
val sh = shorthands[name]
return if (sh != null) "$sh ($stripped)" else stripped
}
fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
}
@@ -0,0 +1,157 @@
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
/**
* Основано на https://github.com/alirzaev/vyatsu-schedule-backend/blob/master/utils/extractors.py
* @author Agzam
*/
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 — жёсткий потолок буфера
/**
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
*
* Страница весит ~2.6MB, а на устройствах с маленькой кучей даже
* одиночный String такого размера — критично (jsoup-DOM и подавно приводил к OOM).
* За один проход извлекаем и список групп, и диапазоны дат из PDF-ссылок.
*/
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 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)
}
}
while (true) {
val n = reader.read(buf)
if (n < 0) break
pending.append(buf, 0, n)
var pos = 0
while (pos < pending.length) {
val divAt = pending.indexOf("<div", pos)
val aAt = pending.indexOf("<a", pos)
val tagStart = when {
divAt < 0 -> aAt
aAt < 0 -> divAt
else -> minOf(divAt, aAt)
}
if (tagStart < 0) {
pos = keepPartialTagTail(pending, pos)
break
}
if (tagStart > pos) {
addRanges(pending.substring(pos, tagStart))
}
val tagEnd = findTagEnd(pending, tagStart)
if (tagEnd < 0) break // тег не дочитан — ждём следующий чанк
val tagText = pending.substring(tagStart, tagEnd + 1)
val isDiv = tagText.startsWith("<div")
val isFakName = isDiv && tagText.contains("class=\"fak_name\"")
val isGrpPeriod = isDiv && tagText.contains("class=\"grpPeriod\"")
if (isFakName || isGrpPeriod) {
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
if (closeDiv < 0) break // содержимое не дочитано — ждём следующий чанк
addRanges(tagText)
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val name = content.trim()
if (name.isNotEmpty()) faculty = FacultyNames.shortname(name)
} else {
val m = grpIdPattern.find(tagText)
if (m != null) {
groups += Group(
id = m.groupValues[1].dropLast(1),
name = FacultyNames.removeParenthesized(content),
faculty = faculty
)
}
}
pos = closeDiv + 6
} else {
addRanges(tagText)
pos = tagEnd + 1
}
if (pos > MAX_SLICE) {
pending.delete(0, pos)
pos = 0
}
}
if (pos > 0 && pos <= pending.length) {
pending.delete(0, pos)
}
if (pending.length > MAX_PENDING) {
// на странице нет настолько длинных конструкций; страховка от аномалий
pending.delete(0, pending.length - MAX_SLICE)
}
}
return 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>>>
)
@@ -0,0 +1,145 @@
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>)
fun parse(html: String): ParsedTeacherSchedule {
val doc = Jsoup.parse(html)
var headerFound = false
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 == "Интервал") {
teachers = tds.drop(2)
.map { cellText(it.selectFirst("span") ?: it) }
.filter { it.isNotEmpty() }
.distinct()
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, if (dayMatch != null) 2 else 1, teachers, 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,
startCol: Int,
teachers: List<String>,
intervals: MutableList<TimeSlot>
) {
val start = LocalTime.parse(intervalMatch.groupValues[1])
val end = LocalTime.parse(intervalMatch.groupValues[2])
val lessons = mutableListOf<Lesson>()
for (i in teachers.indices) {
val cell = tds.getOrNull(startCol + i) ?: continue
lessons += parseCell(cell, teachers[i])
}
if (lessons.isNotEmpty()) intervals.add(TimeSlot(start, end, lessons))
}
private fun parseCell(cell: Element, teacher: String): List<Lesson> {
val raw = cellText(cell)
if (raw.isEmpty()) return emptyList()
val room = TextPatterns.ROOM_REGEX.find(raw)?.value
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.find(raw)
val type = LessonType.fromRaw(typeMatch?.value?.trim() ?: "")
val segments = raw.split(SPLIT_BY_GROUP).map { it.trim() }.filter { it.isNotEmpty() }
val groupSegments = segments.filter { GROUP_TOKEN.containsMatchIn(it) }
if (groupSegments.isEmpty()) {
return listOf(
Lesson(
raw = raw,
room = room,
subject = subjectOf(raw, room, typeMatch),
type = type,
teacher = teacher
)
)
}
val subject = subjectOf(raw, room, typeMatch)
return groupSegments.flatMap { segment ->
val groups = GROUP_TOKEN.findAll(segment).map { it.value }.distinct().toList()
val subgroup = TextPatterns.SUBGROUP_REGEX.find(segment)?.value
groups.map { group ->
Lesson(
raw = segment,
room = room,
group = group,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
}
}
}
private fun subjectOf(raw: String, room: String?, typeMatch: MatchResult?): String? {
val end = typeMatch?.range?.first ?: GROUP_TOKEN.find(raw)?.range?.first ?: raw.length
val prefix = raw.substring(0, end).trim()
val stripped = room?.let { prefix.removePrefix(it).trim() } ?: prefix
return stripped.ifEmpty { null }
}
private fun cellText(el: Element): String = el.text().replace('\u00A0', ' ').trim()
}
@@ -0,0 +1,148 @@
package agzam.vyatsu.data.remote
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Department
import agzam.vyatsu.domain.model.ParsedDepartments
import agzam.vyatsu.domain.model.Season
import java.io.InputStream
/**
* Стриминговый парсер страницы преподавателей: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
* Страница teacher.html весит ~2.4MB, поэтому весь документ грузить нельзя.
*/
object TeachersParser {
private val fakNameTag = Regex("""<div[^>]*class="fak_name"""")
private val fakIdInTag = Regex("""data-fak_id="(\d+)"""")
private val kafPeriodTag = Regex("""<div[^>]*class="kafPeriod"""")
private val kafIdInTag = Regex("""data-kaf_period_id="(\d+)"""")
private val prepodLink = Regex("""href="/reports/schedule/prepod/(\d+)_([12])_(\d{8})_(\d{8})\.html"""")
private val tagStrip = Regex("""<[^>]*>""")
private const val CHUNK_SIZE = 8192
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
fun parseStream(input: InputStream): ParsedDepartments {
val reader = input.bufferedReader(Charsets.UTF_8)
val buf = CharArray(CHUNK_SIZE)
val pending = StringBuilder(CHUNK_SIZE * 2)
val departments = ArrayList<Department>()
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
var faculty = ""
fun addRanges(text: String) {
for (m in prepodLink.findAll(text)) {
val departmentId = m.groupValues[1]
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
val range = DateRange(m.groupValues[3], m.groupValues[4])
ranges.getOrPut(departmentId) {
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
}.getOrPut(season) { ArrayList() }.add(range)
}
}
while (true) {
val n = reader.read(buf)
if (n < 0) break
pending.append(buf, 0, n)
var pos = 0
while (pos < pending.length) {
val divAt = pending.indexOf("<div", pos)
val aAt = pending.indexOf("<a", pos)
val tagStart = when {
divAt < 0 -> aAt
aAt < 0 -> divAt
else -> minOf(divAt, aAt)
}
if (tagStart < 0) {
pos = keepPartialTagTail(pending, pos)
break
}
if (tagStart > pos) {
addRanges(pending.substring(pos, tagStart))
}
val tagEnd = findTagEnd(pending, tagStart)
if (tagEnd < 0) break
val tagText = pending.substring(tagStart, tagEnd + 1)
val isDiv = tagText.startsWith("<div")
val isFakName = isDiv && fakNameTag.containsMatchIn(tagText)
val isKafPeriod = isDiv && kafPeriodTag.containsMatchIn(tagText)
if (isFakName || isKafPeriod) {
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
if (closeDiv < 0) break
addRanges(tagText)
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val stripped = tagStrip.replace(content, "").trim()
if (stripped.isNotEmpty()) faculty = FacultyNames.shortname(stripped)
} else {
val m = kafIdInTag.find(tagText)
if (m != null) {
departments += Department(
departmentId = m.groupValues[1].dropLast(1),
name = tagStrip.replace(content, "").trim(),
faculty = faculty
)
}
}
pos = closeDiv + 6
} else {
addRanges(tagText)
pos = tagEnd + 1
}
if (pos > MAX_SLICE) {
pending.delete(0, pos)
pos = 0
}
}
if (pos > 0 && pos <= pending.length) {
pending.delete(0, pos)
}
if (pending.length > MAX_PENDING) {
pending.delete(0, pending.length - MAX_SLICE)
}
}
return ParsedDepartments(departments, ranges)
}
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
private fun findTagEnd(s: CharSequence, start: Int): Int {
var i = start
var quote = 0.toChar()
while (i < s.length) {
val c = s[i]
if (quote != 0.toChar()) {
if (c == quote) quote = 0.toChar()
} else {
when (c) {
'"', '\'' -> quote = c
'>' -> return i
}
}
i++
}
return -1
}
/**
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
*/
private fun keepPartialTagTail(pending: CharSequence, from: Int): Int {
var i = from
while (i < pending.length) {
val c = pending[i]
if (c == '<') {
val tail = pending.substring(i, pending.length)
if (tail == "<" || tail == "<d" || tail == "<di" || tail == "<a") return i
}
i++
}
return pending.length
}
}
@@ -0,0 +1,50 @@
package agzam.vyatsu.data.remote
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.InputStream
import java.util.concurrent.TimeUnit
/**
* @author Agzam
*/
object VyatsuHttpClient {
private const val TAG = "HTTP"
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
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}")
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
resp.body!!.string()
}
}
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}")
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()
}
}
@@ -0,0 +1,18 @@
package agzam.vyatsu.data.remote
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Season
object VyatsuUrls {
const val GROUPS_PAGE = "https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/raspisanie-zanyatiy-dlya-studentov.html"
const val PDF_BASEURL = "https://www.vyatsu.ru/reports/schedule/Group"
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"
}
@@ -0,0 +1,244 @@
package agzam.vyatsu.data.remote.pdf
import android.graphics.Path
import android.graphics.PointF
import com.tom_roush.pdfbox.contentstream.PDFGraphicsStreamEngine
import com.tom_roush.pdfbox.cos.COSName
import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.pdmodel.graphics.color.PDColor
import com.tom_roush.pdfbox.pdmodel.graphics.image.PDImage
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
/**
* - Парсит страницу PDF в набор линий
* - Позволяет получить таблицу
* - Позволяет конвертировать в SVG (для отладки)
*
* Требуется вызов [processPage] для парсинга
* @author Agzam
*/
class Pdf2Table(page: PDPage) : PDFGraphicsStreamEngine(page) {
data class PdfLine(val x1: Float, val y1: Float, val x2: Float, val y2: Float) {
val left: Float get() = min(x1, x2)
val right: Float get() = max(x1, x2)
val top: Float get() = min(y1, y2)
val bottom: Float get() = max(y1, y2)
}
data class TableColumn(val x: Float, val width: Float, val cells: List<TableCell>) {
val right: Float get() = x + width
}
data class TableCell(val x: Float, val y: Float, val width: Float, val height: Float) {
val centerY: Float get() = y + height/2f
}
private val _lines = mutableListOf<PdfLine>()
private val svgBuilder = StringBuilder()
private val currentPath = StringBuilder()
val tableSvg = StringBuilder()
private val currentSubPathLines = mutableListOf<PdfLine>()
private var lastPoint = PointF(0f, 0f)
/**
* Представляет страницу в SVG формате
*/
fun asSvg(): String {
val mediaBox = page.mediaBox
val width = mediaBox.width
val height = mediaBox.height
return """
<svg width="$width" height="$height" viewBox="0 0 $width $height" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style='font-size: 50%;'>
<g>${svgBuilder}</g>
<g>${tableSvg}</g>
</svg>
""".trimIndent()
}
override fun appendRectangle(rp0: PointF?, rp1: PointF?, rp2: PointF?, rp3: PointF?) {
if (rp0 == null || rp1 == null || rp2 == null || rp3 == null) return
val p0 = t(rp0)
val p1 = t(rp1)
val p2 = t(rp2)
val p3 = t(rp3)
currentPath.append(String.format(Locale.US, "M %.2f %.2f L %.2f %.2f L %.2f %.2f L %.2f %.2f Z ", p0.x, p0.y, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y))
currentSubPathLines.add(PdfLine(p0.x, p0.y, p1.x, p1.y))
currentSubPathLines.add(PdfLine(p1.x, p1.y, p2.x, p2.y))
currentSubPathLines.add(PdfLine(p2.x, p2.y, p3.x, p3.y))
currentSubPathLines.add(PdfLine(p3.x, p3.y, p0.x, p0.y))
lastPoint.set(p3.x, p3.y)
}
override fun moveTo(rx: Float, ry: Float) {
val x = tx(rx)
val y = ty(ry)
currentPath.append(String.format(Locale.US, "M %.2f %.2f ", x, y))
lastPoint.set(x, y)
}
override fun lineTo(rx: Float, ry: Float) {
val x = tx(rx)
val y = ty(ry)
currentPath.append(String.format(Locale.US, "L %.2f %.2f ", x, y))
currentSubPathLines.add(PdfLine(lastPoint.x, lastPoint.y, x, y))
lastPoint.set(x, y)
}
override fun curveTo(rcx1: Float, rcy1: Float, rcx2: Float, rcy2: Float, ex: Float, ey: Float) {
val cx1 = tx(rcx1)
val cy1 = ty(rcy1)
val cx2 = tx(rcx2)
val cy2 = ty(rcy2)
val endX = tx(ex)
val endY = ty(ey)
currentPath.append(String.format(Locale.US, "C %.2f %.2f, %.2f %.2f, %.2f %.2f ", cx1, cy1, cx2, cy2, endX, endY))
lastPoint.set(endX, endY)
}
override fun getCurrentPoint(): PointF {
return lastPoint
}
override fun closePath() {
currentPath.append("Z ")
}
override fun endPath() {
currentPath.clear()
currentSubPathLines.clear()
}
override fun strokePath() {
val strokeColor = graphicsState.strokingColor.toRGBHex()
val lineWidth = graphicsState.lineWidth
if (currentPath.isNotEmpty()) {
svgBuilder.append("<path d=\"$currentPath\" stroke=\"$strokeColor\" stroke-width=\"$lineWidth\" fill=\"none\" />\n")
_lines.addAll(currentSubPathLines)
}
currentPath.clear()
currentSubPathLines.clear()
}
override fun fillPath(p0: Path.FillType?) {
val fillColor = graphicsState.nonStrokingColor.toRGBHex()
val rule = if (p0 == Path.FillType.EVEN_ODD) "evenodd" else "nonzero"
if (currentPath.isNotEmpty()) {
svgBuilder.append("<path d=\"$currentPath\" fill=\"$fillColor\" fill-rule=\"$rule\" />\n")
_lines.addAll(currentSubPathLines)
}
currentPath.clear()
currentSubPathLines.clear()
}
override fun fillAndStrokePath(p0: Path.FillType?) {
val strokeColor = graphicsState.strokingColor.toRGBHex()
val fillColor = graphicsState.nonStrokingColor.toRGBHex()
val lineWidth = graphicsState.lineWidth
val rule = if (p0 == Path.FillType.EVEN_ODD) "evenodd" else "nonzero"
if (currentPath.isNotEmpty()) {
svgBuilder.append("<path d=\"$currentPath\" fill=\"$fillColor\" fill-rule=\"$rule\" stroke=\"$strokeColor\" stroke-width=\"$lineWidth\" />\n")
_lines.addAll(currentSubPathLines)
}
currentPath.clear()
currentSubPathLines.clear()
}
override fun shadingFill(p0: COSName?) {
currentPath.clear()
currentSubPathLines.clear()
}
private fun PDColor.toRGBHex(): String {
return try {
val rgb = this.toRGB()
String.format(Locale.US, "#%06X", rgb and 0xFFFFFF)
} catch (_: Exception) {
"#000000"
}
}
override fun drawImage(p0: PDImage?) {}
override fun clip(p0: Path.FillType?) {}
private fun tx(x: Float): Float {
return x
}
private fun ty(y: Float): Float {
return page.mediaBox.height - y
}
private fun t(p: PointF): PointF {
return PointF(tx(p.x), ty(p.y))
}
/**
* Представляет страницу как набор колонок с ячейками
*
* Сначала делит страницу на столбики, затем, изолированно для каждого столбика делит его на ячейки
*/
fun asTable(): List<TableColumn> {
val verticalLines = _lines.filter { it.x1 == it.x2 }
val horizontalLines = _lines.filter { it.y1 == it.y2 }
// Список уникальных "x", чтобы разделить весь лист на колонки
val xs = merge(verticalLines.map { it.x1 })
for (v in horizontalLines) {
tableSvg.append("<rect fill='#0000ff55' x='${v.left}' y='${v.top}' width='${v.right-v.left}' height='3'/>\n")
}
val columns = mutableListOf<TableColumn>()
for (i in 0 until xs.size - 1) {
val left = xs[i]
val right = xs[i + 1]
tableSvg.append("<rect fill='${if (i % 2 == 0) "#f00" else "#0f0"}' x='$left' y='3' width='${right-left}' height='3'/>\n")
val colWidth = right - left
if (colWidth <= 2f) continue
// Выборка горизонтальных линиий которые пересекают колонку
val linesInColumn = horizontalLines.filter { line -> (line.left-2f <= left && right <= line.right+2f) }
val ys = merge(linesInColumn.map { it.y1 }) // Уникальные "y", чтобы разделить уже столбик
val cells = mutableListOf<TableCell>()
for (ii in 0 until ys.size - 1) {
val top = ys[ii]
val bottom = ys[ii+1]
cells.add(TableCell(left, top, right-left, bottom-top))
tableSvg.append("<rect fill='${if (i % 2 == 0) "#ff" else "#9e"}${if (ii % 2 == 0) "ff9e" else "9e9e"}' x='$left' y='$top' width='${right-left}' height='${bottom-top}'/>\n")
}
columns.add(TableColumn(left, right-left, cells))
}
return columns
}
/**
* Убирает дубликаты, схлопывает близкие точки
*/
private fun merge(fs: List<Float>): List<Float> {
val s = fs.distinct().sorted()
val result = mutableListOf<Float>()
if(s.isEmpty()) return result
result.add(s[0])
var pad = 0f
for (i in 0 until s.size - 1) {
val dst = s[i + 1] - s[i]
if (dst <= 4f) {
pad = dst/2f
result[result.size-1] = result.last() + pad
continue
}
result.add(s[i+1]+pad)
pad = 0f
}
return result
}
}
@@ -0,0 +1,221 @@
package agzam.vyatsu.data.remote.pdf
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.TimeSlot
import android.util.Log
import com.tom_roush.pdfbox.pdmodel.PDDocument
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
/**
* Парсинг расписания из PDF
* @author Agzam
*/
object PdfScheduleParser {
private const val TAG = "PDF-SCHEDULE"
private const val SVG_TAG = "PDF-SVG"
private val TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm")
private val DATE_FORMAT = DateTimeFormatter.ofPattern("dd.MM.yy")
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() }
fun parse(pdfBytes: ByteArray): List<Day> {
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
val doc = try {
PDDocument.load(pdfBytes)
} catch (t: Throwable) {
Log.e(TAG, "PDDocument.load failed", t)
throw t
}
val days = mutableListOf<Day>()
var group: String? = null
try {
for (pageIndex in 0 until doc.numberOfPages) {
Log.v(TAG, "============ Page $pageIndex ============")
// Парсинг ячеек таблицы
val page = doc.getPage(pageIndex)
val svg = Pdf2Table(page)
svg.processPage(page)
val cols = svg.asTable()
if(cols.size != 3) throw RuntimeException("Found columns: ${cols.size}/3")
// Парсинг текста
val textStripper = PdfTextParser()
textStripper.startPage = pageIndex + 1
textStripper.endPage = pageIndex + 1
textStripper.getText(doc)
val texts = textStripper.blocks
for(text in texts) {
svg.tableSvg.appendLine("<text x='${text.x}' y='${text.y}'>${text.text}</text>")
svg.tableSvg.appendLine("<circle cx='${text.x}' cy='${text.y}' r='1' fill='red'/>")
}
val daysColumn = cols[0] // Колонка "День", имеет формат "<день> dd.mm.yy"
val intervalsColumn = cols[1] // Колонка "Интервал", имеет формат "HH:mm-HH:mm"
val lessonsColumn = cols[2] // Колонка с инофрмацией о паре
Log.v(TAG, "Days: ${cols[0].cells.size}")
Log.v(TAG, "Times: ${cols[1].cells.size}")
Log.v(TAG, "Types: ${cols[2].cells.size}")
/** Мерджит ячейки в одну строку, пробел в качестве слиятиля */
fun join(texts: List<PdfTextParser.TextBlock>): String {
val text = StringBuilder()
for (t in texts) {
if(!text.isEmpty()) text.append(' ')
text.append(t.text)
}
return text.toString()
}
/**
* Извлекает контент из PDF внутри ячейки
* (Подразумевается что в ячейке нет "\n", поэтому используется пробел для слияния)
*/
fun content(cell: Pdf2Table.TableCell): String {
return join(texts.filter {
cell.x <= it.x && it.x <= cell.x + cell.width && cell.y <= it.y && it.y < cell.y + cell.height
})
}
/**
* Выбирает все ячейки в целевой колонке, которые находятся на одном уровне (строке) с заданной ячейкой
* @param column - целевая колонка, в которой производится поиск ячеек.
* @param cell - ячейка-ориентир, определяющая границы строки по вертикали.
* @return Список ячеек из [column], попавших в диапазон строки
*/
fun select(column: Pdf2Table.TableColumn, cell: Pdf2Table.TableCell): List<Pdf2Table.TableCell> {
return column.cells.filter {
cell.y <= it.centerY && it.centerY < cell.y + cell.height
}
}
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) {
Log.w(TAG, "Day data is not in format <day> <date>: $day")
continue // XXX: добавить защиту от заголовка и выбрасывать исключения вместо пропуска?
}
val date = dateFrom(day[1]) ?: continue
val intervals = mutableListOf<TimeSlot>()
// TODO: день недели (а нужен ли?)
Log.v(TAG, "############################## $day ############################## ")
for(intervalCell in select(intervalsColumn, dayCell)) { // День содержит интервалы
val timeContent = content(intervalCell)
val range = timeContent.split('-')
if(range.size != 2) continue // XXX: выбрасывать ошибку?
val start = timeFrom(range[0]) ?: continue
val end = timeFrom(range[1]) ?: continue
val lessons = mutableListOf<Lesson>()
Log.v(TAG, "=== $timeContent ===")
for(lessonCell in select(lessonsColumn, intervalCell)) { // Информация о паре, может быть разбита, например на подгруппы
val content = content(lessonCell)
Log.v(TAG, "- $content")
// TODO: парсинг контента лекции
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
var found = 0
var currentStart = 0
for (roomMatch in TextPatterns.ROOM_REGEX.findAll(content)) {
found++
val raw = content.substring(currentStart, roomMatch.range.last + 1)
currentStart = roomMatch.range.last + 1
// Предполагаемый формат: [группа], [XX подгруппа] <предмет> <тип> [id + тип] <ФИО> <кабинет>
val room = roomMatch.value
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.findAll(raw).toList()
if(typeMatch.isEmpty()) {
lessons.add(Lesson(
raw=raw,
room=room,
))
continue
}
var subject = raw.substring(0, typeMatch.first().range.first).trim() // TODO: сейчас [группа], [XX подгруппа] <предмет> - можно достать подгруппу
val type = LessonType.fromRaw(typeMatch.first().value.trim())
val teacher = raw.substring(typeMatch.last().range.last+1, raw.length - room.length).trim()
var subgroup: String? = null
Log.d(TAG, "$subject $group")
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
subject = subject.substring(group.length + 1).trim()
val subgroupMatch = TextPatterns.SUBGROUP_REGEX.find(subject)
if(subgroupMatch != null) {
subgroup = subgroupMatch.value.trim()
subject = subject.removeRange(subgroupMatch.range).trim()
}
}
lessons.add(Lesson(
raw=raw,
subject=subject,
room=room,
type=type,
teacher=teacher,
subgroup=subgroup
))
}
if(found == 0) lessons.add(Lesson(raw=content))
}
intervals.add(TimeSlot(start = start, end = end, lessons = lessons))
}
days.add(Day(date=date, intervals=intervals))
}
for (text in texts.filter { it.rotation != 0f }) {
Log.v(TAG, "${text.x.roundToInt()}\t${text.y.roundToInt()}\t${text.text}")
}
/** Вывод таблички из PDF в SVG формате + отладочные жлементы из [Pdf2Table.asTable] **/
Log.v(SVG_TAG, "====")
Log.v(SVG_TAG+ "-" + (pageIndex+1), "====")
for(line in svg.asSvg().split("\n")) {
if(line.length > 250) {
Log.v(SVG_TAG, "length: " + line.length)
Log.v(SVG_TAG + "-" + (pageIndex+1), "length: " + line.length)
continue
}
Log.v(SVG_TAG, line)
Log.v(SVG_TAG + "-" + (pageIndex+1), line)
}
}
} catch (e: Exception) {
Log.e(TAG, "Parsing error", e)
e.printStackTrace()
} finally {
doc.close()
}
Log.v(TAG, "Days extracted: $days")
return days
}
}
@@ -0,0 +1,76 @@
package agzam.vyatsu.data.remote.pdf
import com.tom_roush.pdfbox.text.PDFTextStripper
import com.tom_roush.pdfbox.text.TextPosition
/**
* Парсер блоков текста из PDF
* @author Agzam
*/
class PdfTextParser : PDFTextStripper() {
data class TextBlock(
val text: String,
val x: Float,
val y: Float,
val width: Float,
val height: Float,
val rotation: Float
)
val blocks = mutableListOf<TextBlock>()
init {
sortByPosition = true
}
override fun writeString(string: String?, textPositions: MutableList<TextPosition>?) {
if (textPositions.isNullOrEmpty()) return
val currentWordChars = mutableListOf<TextPosition>()
for (tp in textPositions) {
val unicode = tp.unicode
if (unicode == " " || unicode == "\u00A0" || unicode == "\t") {
addWordBlock(currentWordChars)
currentWordChars.clear()
} else {
currentWordChars.add(tp)
}
}
addWordBlock(currentWordChars)
}
private fun addWordBlock(wordChars: List<TextPosition>) {
if (wordChars.isEmpty()) return
val first = wordChars.first()
val last = wordChars.last()
val wordText = wordChars.joinToString("") { it.unicode }.trim()
if (wordText.isEmpty()) return
val matrix = first.textMatrix
val rotation = first.dir
val totalWidth = (last.xDirAdj + last.widthDirAdj) - first.xDirAdj
val x = matrix.translateX
val y = matrix.translateY - first.heightDir
val height = first.heightDir
blocks.add(
TextBlock(
text = wordText,
x = x,
y = currentPage.mediaBox.height - y - height,
width = totalWidth,
height = height,
rotation = rotation
)
)
}
}
@@ -0,0 +1,12 @@
package agzam.vyatsu.data.remote.pdf
import agzam.vyatsu.domain.model.LessonType
object TextPatterns {
val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" })
val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
}
@@ -0,0 +1,56 @@
package agzam.vyatsu.data.remote.update
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
@JsonClass(generateAdapter = true)
data class GiteaRelease(
@Json(name = "tag_name") val tagName: String?,
val name: String?,
val body: String?,
val assets: List<GiteaAsset> = emptyList()
)
@JsonClass(generateAdapter = true)
data class GiteaAsset(
val name: String?,
@Json(name = "browser_download_url") val browserDownloadUrl: String?
)
class GiteaReleaseApi(
private val client: OkHttpClient,
private val baseUrl: String = "https://vantablacktea.ddns.net",
private val repoPath: String = "agzam/vyatsu-schedule"
) {
private val moshi = Moshi.Builder().build()
private val releaseAdapter = moshi.adapter(GiteaRelease::class.java)
/**
* Возвращает последний релиз или null, если релизов ещё нет (404).
*/
suspend fun latestRelease(): GiteaRelease? {
val url = "$baseUrl/api/v1/repos/$repoPath/releases/latest"
val request = Request.Builder().url(url).get().build()
return client.newCall(request).execute().use { resp ->
when {
resp.code == 404 -> null
!resp.isSuccessful -> throw IOException("HTTP ${resp.code} при запросе релиза")
else -> {
val body = resp.body ?: throw IOException("Пустой ответ сервера")
releaseAdapter.fromJson(body.source())
}
}
}
}
fun apkDownloadUrl(release: GiteaRelease): String? =
release.assets
.firstOrNull { it.name?.endsWith(".apk", ignoreCase = true) == true }
?.browserDownloadUrl
}
@@ -0,0 +1,86 @@
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.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,
) : GroupRepository {
override fun observeGroups(): Flow<List<Group>> =
groupDao.observeAll().map { list -> list.map { it.toDomain() } }
override suspend fun getGroups(): List<Group> =
groupDao.observeAll().first().map { it.toDomain() }
override suspend fun getFaculties(): List<String> = groupDao.getFaculties()
override suspend fun refreshIfStale(): Boolean {
val last = groupDao.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.GROUPS_PAGE).use(GroupsParser::parseStream)
}
val now = now()
groupDao.insertAll(
parsed.groups.map { group ->
GroupEntity(
id = group.id,
name = group.name,
faculty = group.faculty,
lastSyncedAt = now
)
}
)
val periods = parsed.groups.flatMap { group ->
parsed.dateRanges[group.id].orEmpty().flatMap { (season, ranges) ->
ranges.map { range ->
GroupPeriodEntity(
groupId = group.id,
season = season,
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
)
}
}
}
groupDao.deleteAllPeriods()
groupDao.insertPeriods(periods)
}
private fun now(): Long = System.currentTimeMillis()
private fun GroupEntity.toDomain(): Group = Group(
id = id,
name = name,
faculty = faculty
)
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
}
}
@@ -0,0 +1,32 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.PreferencesDao
import agzam.vyatsu.domain.repository.PreferencesRepository
class PreferencesRepositoryImpl(
private val dao: PreferencesDao
) : PreferencesRepository {
override suspend fun selectedGroupId(): String? = dao.selectedGroupId()
override suspend fun setSelectedGroup(groupId: String) = dao.setSelectedGroup(groupId)
override suspend fun clearSelectedGroup() = dao.clearSelectedGroup()
override suspend fun selectedWeekStart(): String? = dao.selectedWeekStart()
override suspend fun setSelectedWeekStart(weekStart: String) = dao.setSelectedWeekStart(weekStart)
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
override suspend fun selectedTeacherId(): String? = dao.selectedTeacherId()
override suspend fun setSelectedTeacherId(id: String) = dao.setSelectedTeacherId(id)
override suspend fun selectedTeacherWeekStart(): String? = dao.selectedTeacherWeekStart()
override suspend fun setSelectedTeacherWeekStart(weekStart: String) =
dao.setSelectedTeacherWeekStart(weekStart)
override suspend fun selectedTeacherDayDate(): String? = dao.selectedTeacherDayDate()
override suspend fun setSelectedTeacherDayDate(date: String) =
dao.setSelectedTeacherDayDate(date)
override suspend fun clearSelectedTeacher() = dao.clearSelectedTeacher()
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() == "true"
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
override suspend fun autoUpdateAsked(): Boolean = dao.autoUpdateAsked() == "true"
override suspend fun setAutoUpdateAsked(asked: Boolean) = dao.setAutoUpdateAsked(asked.toString())
}
@@ -0,0 +1,214 @@
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
import agzam.vyatsu.data.local.GroupDao
import agzam.vyatsu.data.local.GroupEntity
import agzam.vyatsu.data.remote.pdf.PdfScheduleParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.model.TimeSlot
import agzam.vyatsu.domain.repository.ScheduleRepository
import android.util.Log
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import java.time.LocalDate
import java.time.LocalTime
class ScheduleRepositoryImpl(
private val scheduleDao: ScheduleDao,
private val groupDao: GroupDao
) : ScheduleRepository {
override fun observeSchedule(groupId: String): Flow<Schedule?> =
combine(
scheduleDao.observeSchedules(groupId),
scheduleDao.observeDaysForGroup(groupId),
scheduleDao.observeLessonsForGroup(groupId)
) { schedules, days, lessons ->
buildSchedule(schedules, days, lessons)
}
override suspend fun getSchedule(groupId: String): Schedule? =
buildSchedule(
scheduleDao.getSchedules(groupId),
scheduleDao.getDaysForGroup(groupId),
scheduleDao.getLessonsForGroup(groupId)
)
override suspend fun sync(groupId: String): Schedule? {
Log.d(TAG, "sync(groupId=$groupId) start")
val group = groupDao.getById(groupId) ?: run {
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}")
return null
}
for (period in periods) {
syncPeriod(group, period)
}
scheduleDao.trim(groupId, MAX_PERIODS)
return getSchedule(groupId)
}
private suspend fun syncPeriod(group: GroupEntity, period: GroupPeriodEntity) {
val season = period.season
val range = DateRange(
LocalDate.ofEpochDay(period.weekStart).format(RANGE_FMT),
LocalDate.ofEpochDay(period.weekEnd).format(RANGE_FMT)
)
val existing = scheduleDao.getSchedules(group.id)
.firstOrNull { it.season == season && it.weekStart == period.weekStart }
val isCurrent = ScheduleDates.moscowToday().toEpochDay() in period.weekStart..period.weekEnd
if (existing != null && !isCurrent && now() - existing.lastSyncedAt < STALE_WINDOW_MS) {
Log.d(TAG, "sync: period $range up to date, skip")
return
}
try {
val pdfUrl = VyatsuUrls.groupPdfUrl(group.id, season, range)
Log.d(TAG, "sync: downloading $pdfUrl")
val bytes = VyatsuHttpClient.getBytes(pdfUrl)
Log.d(TAG, "sync: downloaded ${bytes.size} bytes")
val days = PdfScheduleParser.parse(bytes)
Log.d(TAG, "sync: parsed ${days.size} days")
val scheduleId = existing?.id ?: scheduleDao.insertSchedule(
ScheduleEntity(
groupId = group.id,
season = season,
weekStart = period.weekStart,
weekEnd = period.weekEnd,
lastSyncedAt = now()
)
)
replaceDays(scheduleId, days)
Log.d(TAG, "sync: period $range synced")
} catch (e: Throwable) {
Log.e(TAG, "sync: failed for ${group.id} period $range (${e::class.simpleName}: ${e.message})", e)
}
}
override suspend fun isStale(groupId: String): Boolean {
val schedules = scheduleDao.getSchedules(groupId)
val latest = schedules.maxByOrNull { it.weekStart } ?: return true
return now() - latest.lastSyncedAt > STALE_WINDOW_MS
}
override suspend fun clearPeriod(groupId: String, period: SchedulePeriod) {
scheduleDao.deleteSchedule(
groupId = groupId,
season = period.season.name,
weekStart = period.weekStart.toEpochDay()
)
}
override suspend fun clearAll(groupId: String) = scheduleDao.deleteAll(groupId)
private suspend fun replaceDays(scheduleId: Long, days: List<Day>) {
scheduleDao.deleteDaysForSchedule(scheduleId)
val dayEntities = days.map { DayEntity(scheduleId = scheduleId, date = it.date.toEpochDay()) }
val dayIds = scheduleDao.insertDays(dayEntities)
val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) -> LocalDate.ofEpochDay(day.date) to id }
val lessons = days.flatMap { day ->
day.intervals.flatMapIndexed { slotIndex, slot ->
slot.lessons.mapIndexed { lessonIndex, lesson ->
LessonEntity(
dayId = dayIdByDate.getValue(day.date),
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 = lesson.teacher
)
}
}
}
scheduleDao.insertLessons(lessons)
}
private fun buildSchedule(
schedules: List<ScheduleEntity>,
dayEntities: List<DayEntity>,
lessonEntities: List<LessonEntity>
): Schedule? {
if (schedules.isEmpty()) return null
val groupId = schedules.first().groupId
val periods = schedules.sortedBy { it.weekStart }.map {
SchedulePeriod(
weekStart = LocalDate.ofEpochDay(it.weekStart),
weekEnd = LocalDate.ofEpochDay(it.weekEnd),
season = it.season
)
}
val lessonsByDay = lessonEntities.groupBy { it.dayId }
val days = dayEntities
.sortedBy { it.date }
.distinctBy { it.date }
.map { day ->
val lessons = lessonsByDay[day.id].orEmpty()
.groupBy { it.slotIndex }
.toSortedMap()
.map { (_, slotLessons) ->
TimeSlot(
start = LocalTime.of(slotLessons.first().start / 60, slotLessons.first().start % 60),
end = LocalTime.of(slotLessons.first().end / 60, slotLessons.first().end % 60),
lessons = slotLessons
.sortedBy { it.lessonIndex }
.map { it.toDomain() }
)
}
Day(date = LocalDate.ofEpochDay(day.date), intervals = lessons)
}
return Schedule(groupId = groupId, periods = periods, days = days)
}
private fun LessonEntity.toDomain(): Lesson = Lesson(
raw = raw,
room = room,
group = groupName,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
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 TAG = "ScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
}
}
@@ -0,0 +1,86 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DepartmentEntity
import agzam.vyatsu.data.local.DepartmentPeriodEntity
import agzam.vyatsu.data.local.TeacherDao
import agzam.vyatsu.data.remote.TeachersParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Department
import agzam.vyatsu.domain.repository.TeacherRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class TeacherRepositoryImpl(
private val teacherDao: TeacherDao,
) : TeacherRepository {
override fun observeDepartments(): Flow<List<Department>> =
teacherDao.observeDepartments().map { list -> list.map { it.toDomain() } }
override suspend fun getDepartments(): List<Department> =
teacherDao.observeDepartments().first().map { it.toDomain() }
override suspend fun getFaculties(): List<String> = teacherDao.getFaculties()
override suspend fun refreshIfStale(): Boolean {
val last = teacherDao.maxSyncedAt()
if (last != null && now() - last < STALE_WINDOW_MS) {
return true
}
refresh()
return true
}
override suspend fun forceRefresh(): Boolean {
refresh()
return true
}
private suspend fun refresh() {
val parsed = withContext(Dispatchers.IO) {
VyatsuHttpClient.openStream(VyatsuUrls.TEACHER_PAGE).use(TeachersParser::parseStream)
}
val now = now()
teacherDao.insertAll(
parsed.departments.map { department ->
DepartmentEntity(
departmentId = department.departmentId,
name = department.name,
faculty = department.faculty,
lastSyncedAt = now
)
}
)
val periods = parsed.departments.flatMap { department ->
parsed.dateRanges[department.departmentId].orEmpty().flatMap { (season, ranges) ->
ranges.map { range ->
DepartmentPeriodEntity(
departmentId = department.departmentId,
season = season,
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
)
}
}
}
teacherDao.deleteAllPeriods()
teacherDao.insertPeriods(periods)
}
private fun now(): Long = System.currentTimeMillis()
private fun DepartmentEntity.toDomain(): Department = Department(
departmentId = departmentId,
name = name,
faculty = faculty
)
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
}
}
@@ -0,0 +1,294 @@
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.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): 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
}
targets.map { it.season to it.weekStart }
.distinct()
.forEach { (season, weekStart) ->
withContext(Dispatchers.IO) {
scheduleDao.deletePeriod(season.name, weekStart)
}
}
val total = targets.size
val semaphore = Semaphore(PARALLELISM)
val jobs = coroutineScope {
targets.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 = 0
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>,
lessonEntities: List<TeacherLessonEntity>
): TeacherSchedule? {
val weekDays = dayEntities
.filter {
it.date >= period.weekStart.toEpochDay() && it.date <= period.weekEnd.toEpochDay()
}
if (weekDays.isEmpty()) return null
val lessonsByDate = lessonEntities.groupBy { it.date }
val days = weekDays
.sortedBy { it.date }
.distinctBy { it.date }
.map { day ->
val lessons = lessonsByDate[day.date].orEmpty()
Log.d(
TAG,
"build: date=${day.date} totalLessons=${lessons.size} " +
"groupedByStart=${lessons.groupBy { it.start }.keys.sorted()}"
)
val slots = lessons.groupBy { it.start }
.toSortedMap()
.map { (startMin, slotLessons) ->
val first = slotLessons.first()
val times = slotLessons.map { "${it.start}-${it.end}" }.distinct()
val teachers = slotLessons.map { it.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.lessonIndex }
.map { it.toDomain() }
)
}
Day(date = LocalDate.ofEpochDay(day.date), intervals = slots)
}
return TeacherSchedule(period = period, days = days)
}
private fun TeacherLessonEntity.toDomain(): Lesson = Lesson(
raw = raw,
room = room,
group = groupName,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
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")
}
}
@@ -0,0 +1,34 @@
package agzam.vyatsu.data.static
data class Building(
val number: Int,
val address: String,
val latitude: Double,
val longitude: Double
)
val BUILDINGS: List<Building> = listOf(
Building(1, "ул. Московская, д. 36", 58.602692, 49.666638),
Building(2, "ул. Московская, д. 39", 58.604297, 49.665926),
Building(3, "ул. Московская, д. 29", 58.604258, 49.673772),
Building(4, "ул. К.Либкнехта, д. 76 (1 корпус)", 58.602210, 49.665506),
Building(5, "ул. Карла Маркса, д. 77", 58.599995, 49.667861),
Building(6, "Студенческий проезд, д. 9", 58.605119, 49.618132),
Building(7, "ул. Преображенская, д. 32", 58.605315, 49.673662),
Building(8, "Студенческий проезд, д. 11", 58.604574, 49.618105),
Building(9, "Студенческий проезд, д. 11а", 58.604417, 49.617226),
Building(10, "ул. Ломоносова, д. 18а", 58.604644, 49.615911),
Building(11, "ул. Преображенская, д. 41", 58.605713, 49.670755),
Building(12, "ул. Ленина, д. 127", 58.586882, 49.681758),
Building(13, "ул. Красноармейская, д. 26", 58.591304, 49.676543),
Building(14, "ул. Ленина, д. 111", 58.591637, 49.680732),
Building(15, "ул. Ленина, д. 198", 58.567400, 49.686333),
Building(16, "ул. Свободы, д. 122", 58.591296, 49.678238),
Building(17, "ул. Ленина, д. 111а", 58.591030, 49.681094),
Building(18, "ул. Молодой Гвардии, д. 13", 58.598915, 49.687212),
Building(19, "ул. Орловская, д. 12", 58.596234, 49.687395),
Building(20, "ул. Ленина, д. 113", 58.590819, 49.681423),
Building(21, "ул. Ленина, д. 111а", 58.591030, 49.681094),
Building(22, "ул. Ленина, д. 111а", 58.591030, 49.681094),
Building(23, "ул. Володарского, д. 2", 58.616236, 49.671680)
)
@@ -0,0 +1,13 @@
package agzam.vyatsu.data.static
data class CallTime(val start: String, val end: String)
val CALLS: List<CallTime> = listOf(
CallTime("8:20", "9:50"),
CallTime("10:00", "11:30"),
CallTime("11:45", "13:15"),
CallTime("14:00", "15:30"),
CallTime("15:45", "17:15"),
CallTime("17:20", "18:50"),
CallTime("18:55", "20:25")
)
@@ -0,0 +1,61 @@
package agzam.vyatsu.di
import agzam.vyatsu.data.local.AppDatabase
import agzam.vyatsu.data.remote.GroupsParser
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
class AppContainer(context: Context) {
private val database: AppDatabase = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"vyatsu_schedule.db"
)
.addMigrations(AppDatabase.MIGRATION_3_4)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
val groupRepository: GroupRepository by lazy {
GroupRepositoryImpl(
groupDao = database.groupDao(),
)
}
val scheduleRepository: ScheduleRepository by lazy {
ScheduleRepositoryImpl(
scheduleDao = database.scheduleDao(),
groupDao = database.groupDao(),
)
}
val preferencesRepository: PreferencesRepository by lazy {
PreferencesRepositoryImpl(database.preferencesDao())
}
val teacherRepository: TeacherRepository by lazy {
TeacherRepositoryImpl(teacherDao = database.teacherDao())
}
val teacherScheduleRepository: TeacherScheduleRepository by lazy {
TeacherScheduleRepositoryImpl(
scheduleDao = database.teacherScheduleDao(),
teacherDao = database.teacherDao()
)
}
val appUpdater: AppUpdater by lazy {
AppUpdater(context.applicationContext, preferencesRepository)
}
}
@@ -0,0 +1,39 @@
package agzam.vyatsu.domain
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Season
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.temporal.TemporalAdjusters
object ScheduleDates {
private val zone: ZoneId = ZoneId.of("Europe/Moscow")
private val format: DateTimeFormatter = DateTimeFormatter.ofPattern("ddMMyyyy")
fun moscowToday(): LocalDate = LocalDate.now(zone)
fun asDate(dateStr: String): LocalDate = LocalDate.parse(dateStr, format)
fun dayIndexes(firstDate: String, today: LocalDate = moscowToday()): Pair<Int, Int> {
val begin = asDate(firstDate)
val anchor = begin.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY))
val diff = ChronoUnit.DAYS.between(anchor, today)
var week = Math.floorMod(diff / 7, 2)
var day = Math.floorMod(diff, 7)
if (day == 6) {
day = (day + 1) % 7
week = (week + 1) % 2
}
return week to day
}
fun dateByIndexes(firstDate: String, week: Int, day: Int): LocalDate =
asDate(firstDate).plusDays((week * 7 + day).toLong())
fun currentSeason(today: LocalDate = moscowToday()): Season =
if (today.monthValue in 8..12) Season.AUTUMN else Season.SPRING
}
@@ -0,0 +1,7 @@
package agzam.vyatsu.domain.model
data class BulkSyncProgress(
val total: Int,
val done: Int,
val failed: Int
)
@@ -0,0 +1,6 @@
package agzam.vyatsu.domain.model
data class DateRange(
val first: String,
val second: String
)
@@ -0,0 +1,16 @@
package agzam.vyatsu.domain.model
import java.time.LocalDate
/**
* День
* @author Agzam
*/
data class Day(
/** Дата, парсится из формата dd.mm.yy из PDF **/
val date: LocalDate,
/** Список пар **/
val intervals: List<TimeSlot>,
)
@@ -0,0 +1,11 @@
package agzam.vyatsu.domain.model
/**
* Кафедра ВятГУ.
* departmentId — идентификатор кафедры из data-kaf_period_id без последней цифры (как в URL).
*/
data class Department(
val departmentId: String,
val name: String,
val faculty: String
)
@@ -0,0 +1,11 @@
package agzam.vyatsu.domain.model
/**
* Группа студентов ВятГУ.
* id — идентификатор группы из data-grp_period_id без последней цифры (как в PDF URL).
*/
data class Group(
val id: String,
val name: String,
val faculty: String
)
@@ -0,0 +1,29 @@
package agzam.vyatsu.domain.model
/**
* Занятие
* @author Agzam
*/
data class Lesson(
/** Сырое значение из PDF **/
val raw: String,
/** Кабинет **/
val room: String? = null,
/** Группа, например ИВТб-3304-05-00 **/
val group: String? = null,
/** Подгруппа, например на физкультуре **/
val subgroup: String? = null,
/** Предмет **/
val subject: String? = null,
/** Тип, например лекция/практика **/
val type: LessonType = LessonType.OTHER,
/** Преподаватель **/
val teacher: String? = null,
)
@@ -0,0 +1,18 @@
package agzam.vyatsu.domain.model
enum class LessonType(val label: String) {
LECTURE("Лекция"),
PRACTICE("Практическое занятие"),
LABORATORY("Лабораторная работа"),
OTHER("Занятие");
companion object {
fun fromRaw(raw: String): LessonType = when (raw.trim()) {
"Лекция" -> LECTURE
"Практическое занятие" -> PRACTICE
"Лабораторная работа" -> LABORATORY
else -> OTHER
}
}
}
@@ -0,0 +1,6 @@
package agzam.vyatsu.domain.model
data class ParsedDepartments(
val departments: List<Department>,
val dateRanges: Map<String, Map<Season, List<DateRange>>>
)
@@ -0,0 +1,21 @@
package agzam.vyatsu.domain.model
import java.time.LocalDate
data class SchedulePeriod(
val weekStart: LocalDate,
val weekEnd: LocalDate,
val season: Season
)
data class Schedule(
val groupId: String,
val periods: List<SchedulePeriod>,
val days: List<Day>
) {
fun periodOf(date: LocalDate): SchedulePeriod? =
periods.firstOrNull { !date.isBefore(it.weekStart) && !date.isAfter(it.weekEnd) }
fun daysOf(period: SchedulePeriod): List<Day> =
days.filter { !it.date.isBefore(period.weekStart) && !it.date.isAfter(period.weekEnd) }
}
@@ -0,0 +1,11 @@
package agzam.vyatsu.domain.model
enum class Season {
AUTUMN, SPRING;
val apiKey: String
get() = when (this) {
AUTUMN -> "1"
SPRING -> "2"
}
}
@@ -0,0 +1,11 @@
package agzam.vyatsu.domain.model
/**
* Преподаватель ВятГУ.
* Один и тот же преподаватель может вести занятия в нескольких кафедрах,
* поэтому модель не привязана к departmentId — расписание агрегируется
* по всем кафедрам периода.
*/
data class Teacher(
val name: String
)
@@ -0,0 +1,57 @@
package agzam.vyatsu.domain.model
import java.time.LocalDate
import java.time.LocalTime
/**
* Расписание преподавателя за период, агрегированное по всем кафедрам.
*/
data class TeacherSchedule(
val period: SchedulePeriod,
val days: List<Day>
) {
/**
* Возвращает дни периода только с занятиями указанного преподавателя.
* Слоты без занятий отбрасываются, порядок [Day] и [TimeSlot] сохраняется.
*
* Дедупликация — ТОЛЬКО по полному совпадению (дата + слот + аудитория +
* группа + предмет + тип + raw): два занятия коллапсируют в одно лишь когда
* они идентичны целиком (одно физическое занятие, прочитанное из файлов двух
* кафедр). Всё, что отличается хотя бы одним полем, остаётся и показывается.
*/
fun daysForTeacher(teacherName: String): List<Day> =
days.mapNotNull { day ->
val seen = HashSet<FullMatchKey>()
val intervals = day.intervals.mapNotNull { slot ->
val lessons = slot.lessons
.filter { it.teacher == teacherName }
.filter {
seen.add(
FullMatchKey(
date = day.date,
start = slot.start,
end = slot.end,
room = it.room,
group = it.group,
subject = it.subject,
type = it.type,
raw = it.raw
)
)
}
if (lessons.isEmpty()) null else slot.copy(lessons = lessons)
}
if (intervals.isEmpty()) null else day.copy(intervals = intervals)
}
private data class FullMatchKey(
val date: LocalDate,
val start: LocalTime,
val end: LocalTime,
val room: String?,
val group: String?,
val subject: String?,
val type: LessonType,
val raw: String
)
}
@@ -0,0 +1,17 @@
package agzam.vyatsu.domain.model
import java.time.LocalTime
/**
* Временной промежуток/пара (например 08:20-09:30 из PDF)
*
* Может содержать несколько [Lesson], например для разделения на подгруппы
* @author Agzam
*/
data class TimeSlot(
val start: LocalTime,
val end: LocalTime,
val lessons: List<Lesson>
)
@@ -0,0 +1,12 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.Group
import kotlinx.coroutines.flow.Flow
interface GroupRepository {
fun observeGroups(): Flow<List<Group>>
suspend fun getGroups(): List<Group>
suspend fun getFaculties(): List<String>
suspend fun refreshIfStale(): Boolean
suspend fun forceRefresh(): Boolean
}
@@ -0,0 +1,25 @@
package agzam.vyatsu.domain.repository
interface PreferencesRepository {
suspend fun selectedGroupId(): String?
suspend fun setSelectedGroup(groupId: String)
suspend fun clearSelectedGroup()
suspend fun selectedWeekStart(): String?
suspend fun setSelectedWeekStart(weekStart: String)
suspend fun selectedDayDate(): String?
suspend fun setSelectedDayDate(date: String)
suspend fun selectedTeacherId(): String? = null
suspend fun setSelectedTeacherId(id: String) = Unit
suspend fun selectedTeacherWeekStart(): String? = null
suspend fun setSelectedTeacherWeekStart(weekStart: String) = Unit
suspend fun selectedTeacherDayDate(): String? = null
suspend fun setSelectedTeacherDayDate(date: String) = Unit
suspend fun clearSelectedTeacher() = Unit
suspend fun autoUpdateEnabled(): Boolean
suspend fun setAutoUpdateEnabled(enabled: Boolean)
suspend fun autoUpdateAsked(): Boolean
suspend fun setAutoUpdateAsked(asked: Boolean)
}
@@ -0,0 +1,14 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.model.SchedulePeriod
import kotlinx.coroutines.flow.Flow
interface ScheduleRepository {
fun observeSchedule(groupId: String): Flow<Schedule?>
suspend fun getSchedule(groupId: String): Schedule?
suspend fun sync(groupId: String): Schedule?
suspend fun isStale(groupId: String): Boolean
suspend fun clearPeriod(groupId: String, period: SchedulePeriod)
suspend fun clearAll(groupId: String)
}
@@ -0,0 +1,12 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.Department
import kotlinx.coroutines.flow.Flow
interface TeacherRepository {
fun observeDepartments(): Flow<List<Department>>
suspend fun getDepartments(): List<Department>
suspend fun getFaculties(): List<String>
suspend fun refreshIfStale(): Boolean
suspend fun forceRefresh(): Boolean
}
@@ -0,0 +1,17 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.BulkSyncProgress
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Teacher
import agzam.vyatsu.domain.model.TeacherSchedule
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
interface TeacherScheduleRepository {
fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>>
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress>
suspend fun hasData(period: SchedulePeriod): Boolean
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
suspend fun clearAll()
}
@@ -0,0 +1,93 @@
package agzam.vyatsu.ui.home
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
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
private enum class HomeTab(val label: String) {
SCHEDULE("Расписание"),
CALLS("Звонки"),
BUILDINGS("Корпуса"),
SETTINGS("Настройки")
}
@Composable
fun HomeScreen(
scheduleViewModel: ScheduleViewModel,
teacherScheduleViewModel: TeacherScheduleViewModel,
updateViewModel: UpdateViewModel,
onSwitchGroup: () -> Unit,
onSwitchTeacher: () -> Unit
) {
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
Scaffold(
bottomBar = {
NavigationBar {
HomeTab.entries.forEach { t ->
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = {
Icon(
imageVector = when (t) {
HomeTab.SCHEDULE -> Icons.Default.DateRange
HomeTab.CALLS -> Icons.Default.Info
HomeTab.BUILDINGS -> Icons.Default.Home
HomeTab.SETTINGS -> Icons.Default.Settings
},
contentDescription = t.label
)
},
label = { Text(t.label) }
)
}
}
}
) { innerPadding ->
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.CALLS -> CallsScreen()
HomeTab.BUILDINGS -> BuildingsScreen()
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
}
}
}
}
@@ -0,0 +1,58 @@
package agzam.vyatsu.ui.info
import agzam.vyatsu.data.static.BUILDINGS
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import agzam.vyatsu.BuildConfig
@Composable
fun BuildingsScreen() {
val context = LocalContext.current
LazyColumn(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars)
) {
items(BUILDINGS) { b ->
Column(
Modifier
.fillMaxWidth()
.clickable {
val uri = Uri.parse("geo:${b.latitude},${b.longitude}?q=${b.latitude},${b.longitude}(Корпус ${b.number})")
context.startActivity(Intent(Intent.ACTION_VIEW, uri))
}
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
"Корпус ${b.number}",
style = MaterialTheme.typography.bodyLarge
)
Text(
b.address,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
}
}
}
@@ -0,0 +1,42 @@
package agzam.vyatsu.ui.info
import agzam.vyatsu.data.static.CALLS
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun CallsScreen() {
LazyColumn(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars)
) {
itemsIndexed(CALLS) { index, call ->
Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)) {
Text(
"${index + 1} пара",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Text(
"${call.start} – ${call.end}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
}
}
}
@@ -0,0 +1,336 @@
package agzam.vyatsu.ui.picker
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Teacher
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.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.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.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PickerScreen(
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 { innerPadding ->
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(innerPadding)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(
selected = segment == PickerSegment.GROUPS,
onClick = { segment = PickerSegment.GROUPS },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
) {
Text("Группы")
}
SegmentedButton(
selected = segment == PickerSegment.TEACHERS,
onClick = { segment = PickerSegment.TEACHERS },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
) {
Text("Преподаватели")
}
}
Spacer(Modifier.height(16.dp))
when (segment) {
PickerSegment.GROUPS -> GroupSection(
state = state,
onSearch = viewModel::searchGroup,
onRetry = viewModel::retryGroups,
onRefresh = viewModel::refreshGroups,
onGroupPicked = onGroupPicked
)
PickerSegment.TEACHERS -> TeacherSection(
state = state,
onSearch = viewModel::searchTeacher,
onRetry = viewModel::retryTeachers,
onTeacherPicked = onTeacherPicked
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GroupSection(
state: PickerUiState,
onSearch: (String) -> Unit,
onRetry: () -> Unit,
onRefresh: () -> Unit,
onGroupPicked: (Group) -> Unit
) {
var isSearchActive by remember { mutableStateOf(false) }
if (!isSearchActive) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Выберите группу",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
}
}
if (state.groupsLoading && state.groups.isEmpty()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
return
}
if (state.error != null && state.groups.isEmpty()) {
Column(Modifier.fillMaxWidth()) {
Text(
state.error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = onRetry) { Text("Повторить") }
}
return
}
SearchBar(
modifier = Modifier.fillMaxWidth(),
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
inputField = {
SearchBarDefaults.InputField(
query = state.groupQuery,
onQueryChange = onSearch,
onSearch = { isSearchActive = false },
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
placeholder = { Text("Поиск группы") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (isSearchActive && state.groupQuery.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Clear, contentDescription = "Очистить")
}
}
}
)
}
) {
GroupList(
groups = state.filteredGroups,
onPick = { group ->
isSearchActive = false
onGroupPicked(group)
}
)
}
if (!isSearchActive) {
Spacer(modifier = Modifier.height(8.dp))
GroupList(state.filteredGroups, onGroupPicked)
}
}
@Composable
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(groups) { group ->
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(group) }) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
Text(group.name, style = MaterialTheme.typography.bodyMedium)
}
}
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TeacherSection(
state: PickerUiState,
onSearch: (String) -> Unit,
onRetry: () -> Unit,
onTeacherPicked: (Teacher) -> Unit
) {
var isSearchActive by remember { mutableStateOf(false) }
val progress = state.syncProgress
val syncing = progress != null && progress.done < progress.total
if (!isSearchActive) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Выберите преподавателя",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onRetry) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
}
}
if (syncing) {
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
LinearProgressIndicator(
progress = { progress.done / progress.total.toFloat() },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(4.dp))
Text(
text = "Кафедры: ${progress.done}/${progress.total}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (state.teachersLoading && state.teachers.isEmpty() && !syncing) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
return
}
if (state.error != null && state.teachers.isEmpty() && !syncing) {
Column(Modifier.fillMaxWidth()) {
Text(
state.error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = onRetry) { Text("Повторить") }
}
return
}
SearchBar(
modifier = Modifier.fillMaxWidth(),
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
inputField = {
SearchBarDefaults.InputField(
query = state.teacherQuery,
onQueryChange = onSearch,
onSearch = { isSearchActive = false },
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
placeholder = { Text("Поиск преподавателя") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (isSearchActive && state.teacherQuery.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Clear, contentDescription = "Очистить")
}
}
}
)
}
) {
TeacherList(
teachers = state.filteredTeachers,
onPick = { teacher ->
isSearchActive = false
onTeacherPicked(teacher)
}
)
}
if (!isSearchActive) {
Spacer(modifier = Modifier.height(8.dp))
when {
state.teachers.isEmpty() -> Text(
text = "Кафедры без расписания на эту неделю",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
else -> TeacherList(state.filteredTeachers, onTeacherPicked)
}
}
}
@Composable
private fun TeacherList(teachers: List<Teacher>, onPick: (Teacher) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(teachers) { teacher ->
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(teacher) }) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
Text(teacher.name, style = MaterialTheme.typography.bodyMedium)
}
}
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@@ -0,0 +1,3 @@
package agzam.vyatsu.ui.picker
enum class PickerSegment { GROUPS, TEACHERS }
@@ -0,0 +1,196 @@
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)
}
} 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()
}
}
@@ -0,0 +1,189 @@
package agzam.vyatsu.ui.schedule
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.WeekSelector
import agzam.vyatsu.ui.schedule.components.dayName
import agzam.vyatsu.ui.schedule.components.hPadding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@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("Сменить группу")
}
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 = state.weekStart
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
?.let { schedule.daysOf(it) }
.orEmpty()
val day = schedule.days.firstOrNull { it.date == state.dayDate }
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),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
WeekSelector(
periods = schedule.periods,
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate?.let { schedule.periodOf(it)?.weekStart },
onSelect = viewModel::selectWeek
)
}
HorizontalDivider(Modifier.padding(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 day = weekDays.getOrNull(page)
if (day == null || day.intervals.all { it.lessons.isEmpty() }) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(day.intervals) { slotIndex, slot ->
LessonSlot(slotIndex = slotIndex, slot = slot)
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,197 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Schedule
import agzam.vyatsu.domain.repository.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.LocalDate
class ScheduleViewModel(
private val scheduleRepository: ScheduleRepository,
private val preferencesRepository: PreferencesRepository,
private val groupRepository: GroupRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ScheduleUiState())
val uiState: StateFlow<ScheduleUiState> = _uiState.asStateFlow()
private var currentGroupId: String? = null
init {
viewModelScope.launch {
val groupId = preferencesRepository.selectedGroupId()
if (groupId != null) {
selectGroup(groupId)
} else {
_uiState.value = _uiState.value.copy(noGroup = true, groupName = null)
}
}
}
fun selectGroup(groupId: String) {
currentGroupId = groupId
_uiState.value = _uiState.value.copy(noGroup = false, isLoading = true, loadError = false)
viewModelScope.launch {
preferencesRepository.setSelectedGroup(groupId)
val groupName = groupRepository.getGroups().firstOrNull { it.id == groupId }?.name
val savedWeekStart = runCatching {
preferencesRepository.selectedWeekStart()?.let(LocalDate::parse)
}.getOrNull()
val savedDayDate = runCatching {
preferencesRepository.selectedDayDate()?.let(LocalDate::parse)
}.getOrNull()
_uiState.value = _uiState.value.copy(
noGroup = false,
groupName = groupName,
weekStart = savedWeekStart,
dayDate = savedDayDate
)
launch {
scheduleRepository.observeSchedule(groupId).collect { schedule ->
currentGroupId?.let { applySchedule(schedule) }
}
}
launch {
syncIfMissing(groupId)
}
}
}
private suspend fun applySchedule(schedule: Schedule?) {
val current = _uiState.value
if (schedule == null || schedule.periods.isEmpty()) {
_uiState.value = current.copy(schedule = schedule, todayDate = null)
return
}
val today = ScheduleDates.moscowToday()
val todayDate = if (schedule.periodOf(today) != null) today else null
val weekStart = when {
schedule.periods.any { it.weekStart == current.weekStart } -> current.weekStart!!
todayDate != null -> schedule.periodOf(todayDate)!!.weekStart
else -> schedule.periods.first().weekStart
}
val period = schedule.periods.first { it.weekStart == weekStart }
val weekDays = schedule.daysOf(period)
val dayDate = when {
weekDays.any { it.date == current.dayDate } -> current.dayDate
weekDays.any { it.date == todayDate } -> todayDate
else -> weekDays.firstOrNull()?.date
}
preferencesRepository.setSelectedWeekStart(weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
_uiState.value = current.copy(
schedule = schedule,
weekStart = weekStart,
dayDate = dayDate,
todayDate = todayDate,
isLoading = false,
loadError = false
)
}
fun selectWeek(weekStart: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
viewModelScope.launch {
val today = ScheduleDates.moscowToday()
val weekDays = schedule.daysOf(period)
val dayDate = when {
weekDays.any { it.date == today } -> today
else -> weekDays.firstOrNull()?.date
}
preferencesRepository.setSelectedWeekStart(weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedDayDate(it.toString()) }
_uiState.value = _uiState.value.copy(weekStart = weekStart, dayDate = dayDate)
}
}
fun selectDay(date: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
val period = schedule.periodOf(date) ?: return
viewModelScope.launch {
preferencesRepository.setSelectedWeekStart(period.weekStart.toString())
preferencesRepository.setSelectedDayDate(date.toString())
_uiState.value = _uiState.value.copy(weekStart = period.weekStart, dayDate = date)
}
}
private suspend fun syncIfMissing(groupId: String) {
if (scheduleRepository.getSchedule(groupId) != null) {
_uiState.value = _uiState.value.copy(isLoading = false)
return
}
loadSchedule(groupId)
}
private suspend fun loadSchedule(groupId: String) {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
val schedule = scheduleRepository.sync(groupId)
_uiState.value = if (schedule != null) {
_uiState.value.copy(isLoading = false, loadError = false)
} else {
_uiState.value.copy(isLoading = false, loadError = true)
}
}
fun switchGroup() {
viewModelScope.launch {
currentGroupId = null
preferencesRepository.clearSelectedGroup()
_uiState.value = _uiState.value.copy(noGroup = true, isLoading = false, loadError = false)
}
}
fun refresh() {
val groupId = currentGroupId ?: return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
// Обновляем список групп: так подхватываются новые PDF (периоды), появившиеся на сайте.
runCatching { groupRepository.forceRefresh() }
loadSchedule(groupId)
}
}
fun clearAll() {
val groupId = currentGroupId ?: return
viewModelScope.launch {
scheduleRepository.clearAll(groupId)
}
}
fun clearSelectedPeriod() {
val state = _uiState.value
val schedule = state.schedule ?: return
val weekStart = state.weekStart ?: return
val period = schedule.periods.firstOrNull { it.weekStart == weekStart } ?: return
viewModelScope.launch {
scheduleRepository.clearPeriod(schedule.groupId, period)
}
}
}
data class ScheduleUiState(
val noGroup: Boolean = false,
val isLoading: Boolean = false,
val loadError: Boolean = false,
val schedule: Schedule? = null,
val groupName: String? = null,
val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null
)
@@ -0,0 +1,133 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.Day
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.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.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(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.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)
)
}
}
}
@@ -0,0 +1,118 @@
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.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.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) {
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
val meta = listOfNotNull(
if (lesson.type == LessonType.OTHER) {
null
} else {
lesson.type.label
},
lesson.subgroup,
if (showTeacher) lesson.teacher else lesson.group,
lesson.room
).filter { it.isNotBlank() }.joinToString(" · ")
if (meta.isNotEmpty()) {
Text(
meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = hPadding)
)
any = true
}
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if (subject.isNotEmpty()) {
Text(
subject,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = hPadding)
)
any = true
}
if (!any) {
Text(
"пусто",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = hPadding)
)
}
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package agzam.vyatsu.ui.schedule.components
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.format.DateTimeFormatter
internal val hPadding = 16.dp
internal fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
DayOfWeek.MONDAY -> "Пн"
DayOfWeek.TUESDAY -> "Вт"
DayOfWeek.WEDNESDAY -> "Ср"
DayOfWeek.THURSDAY -> "Чт"
DayOfWeek.FRIDAY -> "Пт"
DayOfWeek.SATURDAY -> "Сб"
DayOfWeek.SUNDAY -> "Вс"
}
internal val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
internal val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
internal val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
@@ -0,0 +1,96 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.SchedulePeriod
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun WeekSelector(
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.clickable(enabled = true, onClick = { expanded = true })
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
selected?.let(::weekLabel) ?: "Период",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.widthIn(min = 220.dp)
) {
periods.forEach { period ->
val isTodayWeek = todayWeekStart == period.weekStart
DropdownMenuItem(
text = {
Text(
weekLabel(period),
style = MaterialTheme.typography.bodyLarge
)
},
trailingIcon = if (isTodayWeek) {
{
Text(
"сегодня",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} else {
null
},
onClick = {
expanded = false
onSelect(period.weekStart)
}
)
}
}
}
}
internal fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@@ -0,0 +1,152 @@
package agzam.vyatsu.ui.settings
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.update.UpdateState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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.unit.dp
@Composable
fun SettingsScreen(viewModel: UpdateViewModel) {
val autoEnabled by viewModel.autoEnabled.collectAsState()
val updateState by viewModel.updateState.collectAsState()
LazyColumn(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars)
) {
item { SectionHeader("Обновления") }
item {
SettingsItem(
title = "Автоматические обновления",
subtitle = "Проверять и устанавливать новые версии при запуске",
trailing = {
Switch(
checked = autoEnabled,
onCheckedChange = { viewModel.setAutoEnabled(it) }
)
}
)
SettingsDivider()
}
item {
SettingsItem(
title = "Проверить наличие обновлений",
subtitle = updateStatusText(updateState, autoEnabled),
onClick = { viewModel.checkNow() }
)
SettingsDivider()
}
if (autoEnabled && (updateState is UpdateState.Available || updateState is UpdateState.ReadyToInstall)) {
item {
SettingsItem(
title = "Установить обновление",
subtitle = installHint(updateState, viewModel),
onClick = {
when (updateState) {
is UpdateState.Available -> viewModel.downloadAndInstall()
is UpdateState.ReadyToInstall -> viewModel.installDownloadedApk()
else -> {}
}
}
)
SettingsDivider()
}
}
item { SectionHeader("О приложении") }
item {
SettingsItem(
title = "Версия",
trailing = {
Text(
BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
SettingsDivider()
}
}
}
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
private fun SettingsItem(
title: String,
subtitle: String? = null,
trailing: (@Composable () -> Unit)? = null,
onClick: (() -> Unit)? = null
) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
@Composable
private fun SettingsDivider() {
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
}
@@ -0,0 +1,58 @@
package agzam.vyatsu.ui.settings
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.update.AppUpdater
import agzam.vyatsu.update.UpdateState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class UpdateViewModel(
private val updater: AppUpdater,
private val preferencesRepository: PreferencesRepository
) : ViewModel() {
val updateState: StateFlow<UpdateState> = updater.state
private val _autoEnabled = MutableStateFlow(false)
val autoEnabled: StateFlow<Boolean> = _autoEnabled.asStateFlow()
private val _showFirstRunDialog = MutableStateFlow(false)
val showFirstRunDialog: StateFlow<Boolean> = _showFirstRunDialog.asStateFlow()
init {
viewModelScope.launch {
_autoEnabled.value = preferencesRepository.autoUpdateEnabled()
_showFirstRunDialog.value = !preferencesRepository.autoUpdateAsked()
}
}
fun setAutoEnabled(enabled: Boolean) {
_autoEnabled.value = enabled
updater.setAutoEnabled(enabled)
if (enabled && !updater.canRequestInstalls()) {
updater.openUnknownSourcesSettings()
}
}
fun onFirstRunDecision(enable: Boolean) {
_showFirstRunDialog.value = false
viewModelScope.launch {
preferencesRepository.setAutoUpdateAsked(true)
}
setAutoEnabled(enable)
}
fun checkNow() = updater.checkForUpdate()
fun downloadAndInstall() = updater.downloadAndInstall()
fun installDownloadedApk() = updater.installDownloadedApk()
fun canRequestInstalls() = updater.canRequestInstalls()
fun openUnknownSourcesSettings() = updater.openUnknownSourcesSettings()
}
@@ -0,0 +1,188 @@
package agzam.vyatsu.ui.teacher
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.WeekSelector
import agzam.vyatsu.ui.schedule.components.dayName
import agzam.vyatsu.ui.schedule.components.hPadding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher: () -> Unit) {
val state by viewModel.uiState.collectAsState()
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(state.teacherName ?: "Расписание преподавателя", style = MaterialTheme.typography.titleMedium) },
actions = {
if (state.isLoading && state.schedule != null) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
TextButton(onClick = { onSwitchTeacher() }) {
Text("Сменить преподавателя")
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
},
windowInsets = WindowInsets(0.dp)
)
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
when {
state.schedule == null && state.loadError -> {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Не удалось загрузить расписание",
style = MaterialTheme.typography.bodyLarge
)
TextButton(onClick = { viewModel.refresh() }) {
Text("Повторить")
}
}
}
state.schedule == null -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
else -> {
val schedule = state.schedule!!
val weekDays = schedule.days
val day = weekDays.firstOrNull { it.date == state.dayDate }
if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
val pagerState = rememberPagerState(
initialPage = selectedDayIndex,
pageCount = { weekDays.size }
)
LaunchedEffect(state.dayDate) {
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
if (targetPage != -1 && targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage)
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (pagerState.isScrollInProgress && page in weekDays.indices) {
viewModel.selectDay(weekDays[page].date)
}
}
}
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
Row(
Modifier.fillMaxWidth().padding(horizontal = hPadding),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
WeekSelector(
periods = state.weeks.ifEmpty { listOf(schedule.period) },
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate
?.let { todayDate ->
state.weeks.firstOrNull { period ->
!todayDate.isBefore(period.weekStart) && !todayDate.isAfter(period.weekEnd)
}?.weekStart
}
?: schedule.period.weekStart.takeIf {
state.todayDate?.let { today -> !today.isBefore(it) && !today.isAfter(it.plusDays(6)) } == true
},
onSelect = viewModel::selectWeek
)
}
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
DayChips(
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
onSelect = viewModel::selectDay,
pagerState = pagerState
)
HorizontalDivider(Modifier.padding(top = 0.dp, start = hPadding, end = hPadding))
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
val pagerDay = weekDays.getOrNull(page)
if (pagerDay == null || pagerDay.intervals.all { it.lessons.isEmpty() }) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(pagerDay.intervals) { slotIndex, slot ->
LessonSlot(slotIndex = slotIndex, slot = slot, showTeacher = false)
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,241 @@
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 {
scheduleRepository.syncPeriod(period.weekStart).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()).collect { }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
}
if (revision != loadRevision) return@launch
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
}
}
fun switchTeacher() {
loadRevision++
viewModelScope.launch {
currentTeacherName = null
currentPeriod = null
scheduleJob?.cancel()
scheduleJob = null
preferencesRepository.clearSelectedTeacher()
_uiState.value = TeacherScheduleUiState(noTeacher = true)
}
}
private fun isInPeriod(date: LocalDate, period: SchedulePeriod): Boolean =
!date.isBefore(period.weekStart) && !date.isAfter(period.weekEnd)
}
data class TeacherScheduleUiState(
val noTeacher: Boolean = true,
val isLoading: Boolean = false,
val loadError: Boolean = false,
val schedule: TeacherSchedule? = null,
val teacherName: String? = null,
val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null,
val weeks: List<SchedulePeriod> = emptyList()
)
@@ -0,0 +1,11 @@
package agzam.vyatsu.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,58 @@
package agzam.vyatsu.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun VyatsuScheduleTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package agzam.vyatsu.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
@@ -0,0 +1,217 @@
package agzam.vyatsu.update
import agzam.vyatsu.BuildConfig
import agzam.vyatsu.data.remote.update.GiteaReleaseApi
import agzam.vyatsu.domain.repository.PreferencesRepository
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Log
import androidx.core.content.FileProvider
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed interface UpdateState {
data object Idle : UpdateState
data object Checking : UpdateState
data object UpToDate : UpdateState
data class Available(val version: String, val notes: String?) : UpdateState
data class Downloading(val progress: Int?) : UpdateState
data object ReadyToInstall : UpdateState
data class Failed(val message: String) : UpdateState
}
class AppUpdater(
private val context: Context,
private val preferencesRepository: PreferencesRepository
) {
private val scope = CoroutineScope(Dispatchers.IO)
private val client = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
private val api = GiteaReleaseApi(client)
private val _state = MutableStateFlow<UpdateState>(UpdateState.Idle)
val state: StateFlow<UpdateState> = _state.asStateFlow()
private var job: Job? = null
private var pendingUpdate: AvailableUpdate? = null
private var downloadedApk: File? = null
fun autoCheckAndInstall() {
if (job?.isActive == true) return
job = scope.launch {
if (!preferencesRepository.autoUpdateEnabled()) return@launch
runCatching {
val update = check() ?: return@launch
install(download(update))
}.onFailure(::onUpdateFailed)
}
}
fun checkForUpdate() {
if (job?.isActive == true) return
job = scope.launch {
runCatching { check() }.onFailure(::onUpdateFailed)
}
}
fun downloadAndInstall() {
if (job?.isActive == true) return
val update = pendingUpdate ?: return
job = scope.launch {
if (!preferencesRepository.autoUpdateEnabled()) return@launch
runCatching { install(download(update)) }.onFailure(::onUpdateFailed)
}
}
fun installDownloadedApk() {
val apk = downloadedApk ?: return
install(apk)
}
fun setAutoEnabled(enabled: Boolean) {
if (!enabled) {
job?.cancel()
if (_state.value is UpdateState.Downloading || _state.value is UpdateState.Checking) {
_state.value = UpdateState.Idle
}
}
scope.launch {
preferencesRepository.setAutoUpdateEnabled(enabled)
}
}
fun canRequestInstalls(): Boolean =
context.packageManager.canRequestPackageInstalls()
fun openUnknownSourcesSettings() {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}")
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { context.startActivity(intent) }
}
private suspend fun check(): AvailableUpdate? {
_state.value = UpdateState.Checking
val release = api.latestRelease()
if (release == null) {
_state.value = UpdateState.UpToDate
return null
}
val remoteVersion = release.tagName?.trimStart('v', 'V')
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME.trimStart('v', 'V'))) {
_state.value = UpdateState.UpToDate
return null
}
val apkUrl = api.apkDownloadUrl(release)
?: throw IOException("В релизе $remoteVersion нет APK-файла")
val update = AvailableUpdate(version = remoteVersion, apkUrl = apkUrl)
pendingUpdate = update
_state.value = UpdateState.Available(update.version, release.body)
return update
}
private suspend fun download(update: AvailableUpdate): File {
_state.value = UpdateState.Downloading(null)
val dir = File(context.cacheDir, "updates").apply { mkdirs() }
val target = File(dir, "update-${update.version}.apk")
downloadedApk?.delete()
val response = client.newCall(Request.Builder().url(update.apkUrl).get().build()).execute()
response.use { resp ->
if (!resp.isSuccessful) throw IOException("HTTP ${resp.code} при скачивании")
val body = resp.body ?: throw IOException("Пустой ответ при скачивании")
val contentLength = body.contentLength()
body.byteStream().use { input ->
target.outputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE_BYTES)
var readTotal = 0L
while (true) {
val read = input.read(buffer)
if (read == -1) break
output.write(buffer, 0, read)
readTotal += read
if (contentLength > 0) {
_state.value = UpdateState.Downloading((readTotal * 100 / contentLength).toInt())
}
}
}
}
}
if (target.length() == 0L) {
target.delete()
throw IOException("Скачанный файл пуст")
}
downloadedApk = target
_state.value = UpdateState.ReadyToInstall
return target
}
private fun install(apk: File) {
if (!canRequestInstalls()) {
return
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apk
)
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
data = uri
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
.onFailure { Log.e(TAG, "Не удалось запустить установку", it) }
}
private fun onUpdateFailed(error: Throwable) {
if (error is CancellationException) throw error
Log.e(TAG, "Ошибка обновления", error)
_state.value = UpdateState.Failed(error.message ?: "Неизвестная ошибка")
}
private data class AvailableUpdate(val version: String, val apkUrl: String)
private fun isNewerVersion(remote: String, local: String): Boolean {
val remoteParts = remote.split('.').map { it.toLongOrNull() ?: 0L }
val localParts = local.split('.').map { it.toLongOrNull() ?: 0L }
val size = maxOf(remoteParts.size, localParts.size)
for (i in 0 until size) {
val r = remoteParts.getOrElse(i) { 0L }
val l = localParts.getOrElse(i) { 0L }
if (r != l) return r > l
}
return false
}
private companion object {
const val TAG = "AppUpdate"
const val BUFFER_SIZE_BYTES = 8 * 1024
}
}
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<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" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
+109
View File
@@ -0,0 +1,109 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="100dp"
android:height="100dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M1,6L1,18L19,18L19,6"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF5581D5"/>
<item android:offset="1" android:color="#FF4063CD"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF5581D5"/>
<item android:offset="1" android:color="#FF4063CD"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M19,18L1,18C12,18 19,14 19,6Z"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF6598E3"/>
<item android:offset="1" android:color="#FF74A2E6"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="18"
android:endX="19"
android:endY="6"
android:type="linear">
<item android:offset="0" android:color="#FF6598E3"/>
<item android:offset="1" android:color="#FF74A2E6"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M4,4C4,2 6,2 6,4L14,4C14,2 16,2 16,4L19,4L19,6L1,6L1,4Z"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="1"
android:startY="2.5"
android:endX="19"
android:endY="2.5"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
<aapt:attr name="android:strokeColor">
<gradient
android:startX="1"
android:startY="2.5"
android:endX="19"
android:endY="2.5"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M3,8L17,8C18,8 18,9 17,9L3,9C2,9 2,8 3,8M3,11L14,11C15,11 15,12 14,12L3,12C2,12 2,11 3,11M4,14L12,14C13,14 13,15 12,15L5,15C4,15 4,14 5,14M3,14C4,14 4,15 3,15C2,15 2,14 3,14"
android:strokeLineJoin="round"
android:strokeWidth="0"
android:strokeColor="#00000000"
android:strokeLineCap="round">
<aapt:attr name="android:fillColor">
<gradient
android:startX="2.25"
android:startY="8"
android:endX="17.75"
android:endY="8"
android:type="linear">
<item android:offset="0" android:color="#FF4260C4"/>
<item android:offset="1" android:color="#FF0E2CB0"/>
</gradient>
</aapt:attr>
</path>
</vector>
+35
View File
@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100px" height="100px" viewBox="-10 -10 20 20">
<defs>
<linearGradient id="accent">
<stop offset="0%" stop-color="#4260c4" />
<stop offset="100%" stop-color="#0e2cb0" />
</linearGradient>
<linearGradient id="bg" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#5581d5" />
<stop offset="100%" stop-color="#4063cd" />
</linearGradient>
<linearGradient id="bga" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6598e3" />
<stop offset="100%" stop-color="#74a2e6" />
</linearGradient>
</defs>
<g stroke-width="0" stroke-linejoin="round" stroke-linecap="round">
<!-- <path stroke="#111" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/> -->
<path stroke="url(#bg)" fill="url(#bg)" d="M -9 -4 L -9 8 L 9 8 L 9 -4"/>
<path stroke="url(#bga)" fill="url(#bga)" d="M 9 8 L -9 8 C 2 8 9 4 9 -4 Z"/>
<!-- <path stroke="#111" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/> -->
<g stroke="url(#accent)">
<path fill="url(#accent)" d="M -6 -6 C -6 -8 -4 -8 -4 -6 L 4 -6 C 4 -8 6 -8 6 -6 L 9 -6 L 9 -4 L -9 -4 L -9 -6 Z"/>
<path fill="url(#accent)" stroke="none" d="M -7 -2 L 7 -2 C 8 -2 8 -1 7 -1 L -7 -1 C -8 -1 -8 -2 -7 -2 M -7 1 L 4 1 C 5 1 5 2 4 2 L -7 2 C -8 2 -8 1 -7 1 M -6 4 L 2 4 C 3 4 3 5 2 5 L -5 5 C -6 5 -6 4 -5 4 M -7 4 C -6 4 -6 5 -7 5 C -8 5 -8 4 -7 4"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,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" />
</adaptive-icon>
@@ -0,0 +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" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Some files were not shown because too many files have changed in this diff Show More