Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f58466d25 | ||
|
|
c6f280ff72 | ||
|
|
f8b4e8cfd3 | ||
|
|
1fa2f53d35 | ||
|
|
5e22c3a391 | ||
|
|
8df7f7c74c | ||
|
|
45139640eb | ||
|
|
aa568cb628 | ||
|
|
4160a79574 | ||
|
|
8d04c10f48 | ||
|
|
02bfe60e8b | ||
|
|
4ff7b1a5ea |
@@ -18,19 +18,50 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
releases: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Verify Environment
|
- name: Set up JDK 17
|
||||||
|
# Ставим JDK вручную и явно прописываем JAVA_HOME.
|
||||||
|
# actions/setup-java не всегда передаёт JAVA_HOME дальше в Gitea Actions.
|
||||||
run: |
|
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
|
java -version
|
||||||
echo "SDK path: $ANDROID_HOME"
|
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
|
- name: Grant execute permission for gradlew
|
||||||
run: |
|
run: |
|
||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
|
java -version
|
||||||
|
|
||||||
# Версия по дате (ГГГГ.ММ.ДД), при совпадении тега добавляем уникальный суффикс.
|
# Версия по дате (ГГГГ.ММ.ДД), при совпадении тега добавляем уникальный суффикс.
|
||||||
- name: Generate version from date
|
- name: Generate version from date
|
||||||
@@ -43,9 +74,9 @@ jobs:
|
|||||||
SUFFIX=$((SUFFIX + 1))
|
SUFFIX=$((SUFFIX + 1))
|
||||||
TAG="v${BASE}.${SUFFIX}"
|
TAG="v${BASE}.${SUFFIX}"
|
||||||
done
|
done
|
||||||
echo "version_name=$TAG" >> "$GITHUB_OUTPUT"
|
echo "version_name=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||||
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
|
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
echo "Generated version: $BASE"
|
echo "Generated version: ${TAG#v} (tag: $TAG)"
|
||||||
|
|
||||||
- name: Restore keystore from secret
|
- name: Restore keystore from secret
|
||||||
# Ключ подписи передаётся как base64-секрет и декодируется в файл.
|
# Ключ подписи передаётся как base64-секрет и декодируется в файл.
|
||||||
@@ -71,7 +102,7 @@ jobs:
|
|||||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
ORG_GRADLE_JVMARGS: '-Xmx2048m'
|
ORG_GRADLE_JVMARGS: '-Xmx2048m'
|
||||||
GRADLE_OPTS: '-Dorg.gradle.daemon=false'
|
GRADLE_OPTS: '-Dorg.gradle.daemon=true'
|
||||||
|
|
||||||
- name: Determine APK path
|
- name: Determine APK path
|
||||||
id: apk
|
id: apk
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
name: Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
unit-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Install Android SDK
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: Run JVM unit tests
|
||||||
|
run: ./gradlew :app:testDebugUnitTest --console=plain
|
||||||
|
env:
|
||||||
|
ANDROID_HOME: /opt/android-sdk
|
||||||
|
GRADLE_OPTS: '-Dorg.gradle.daemon=false'
|
||||||
@@ -1,8 +1,2 @@
|
|||||||
# ВятГУ расписание _(неофициальный Android клиент)_
|
# vyatsu-schedule
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Расписание загружается и парсится прямиком с сайта ВятГУ, и сохраняется в локальную БД телефона без промежуточных серверов
|
|
||||||
|
|
||||||
- Расписание групп - парсится из PDF-ок ([тута](https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/raspisanie-zanyatiy-dlya-studentov.html))
|
|
||||||
- Расписание преподавателей - загружаются все расписания преподавателей [отсюда](https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/teacher.html) на выбранный переиод (ибо раскиданы по кафедрам)
|
|
||||||
@@ -40,8 +40,7 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = false
|
||||||
isShrinkResources = true
|
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
@@ -110,11 +109,7 @@ dependencies {
|
|||||||
implementation(libs.pdfbox.android)
|
implementation(libs.pdfbox.android)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation("org.apache.pdfbox:pdfbox:3.0.4")
|
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-19
@@ -19,22 +19,3 @@
|
|||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
# Игнорировать отсутствие класса декодера JP2 в PdfBox-Android
|
|
||||||
-dontwarn com.gemalto.jp2.JP2Decoder
|
|
||||||
# На всякий случай, если PdfBox ссылается на другие отсутствующие библиотеки (например, ICU для текста)
|
|
||||||
-dontwarn com.tom_roush.pdfbox.**
|
|
||||||
|
|
||||||
# Запретить R8 удалять и переименовывать внутренности PdfBox-Android
|
|
||||||
-keep class com.tom_roush.pdfbox.** { *; }
|
|
||||||
-keep interface com.tom_roush.pdfbox.** { *; }
|
|
||||||
|
|
||||||
# Moshi удаляется proguard
|
|
||||||
-keepnames @com.squareup.moshi.JsonClass class *
|
|
||||||
-keep class **JsonAdapter {
|
|
||||||
public <init>(...);
|
|
||||||
<fields>;
|
|
||||||
}
|
|
||||||
-keepclassmembers @com.squareup.moshi.JsonClass class * {
|
|
||||||
synthetic <init>(...);
|
|
||||||
}
|
|
||||||
@@ -1,818 +0,0 @@
|
|||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 4,
|
|
||||||
"identityHash": "38abab3f8c5b39625b6a4de9d58968ca",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "groups",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "faculty",
|
|
||||||
"columnName": "faculty",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncedAt",
|
|
||||||
"columnName": "lastSyncedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "group_periods",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "groupId",
|
|
||||||
"columnName": "groupId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "season",
|
|
||||||
"columnName": "season",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "weekStart",
|
|
||||||
"columnName": "weekStart",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "weekEnd",
|
|
||||||
"columnName": "weekEnd",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_group_periods_groupId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"groupId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "groups",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"groupId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "schedules",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "groupId",
|
|
||||||
"columnName": "groupId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "season",
|
|
||||||
"columnName": "season",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "weekStart",
|
|
||||||
"columnName": "weekStart",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "weekEnd",
|
|
||||||
"columnName": "weekEnd",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncedAt",
|
|
||||||
"columnName": "lastSyncedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_schedules_groupId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"groupId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_schedules_groupId_season_weekStart",
|
|
||||||
"unique": true,
|
|
||||||
"columnNames": [
|
|
||||||
"groupId",
|
|
||||||
"season",
|
|
||||||
"weekStart"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "groups",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"groupId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "days",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "scheduleId",
|
|
||||||
"columnName": "scheduleId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "date",
|
|
||||||
"columnName": "date",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_days_scheduleId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"scheduleId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_days_date",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"date"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "schedules",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"scheduleId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "lessons",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "dayId",
|
|
||||||
"columnName": "dayId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "date",
|
|
||||||
"columnName": "date",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "slotIndex",
|
|
||||||
"columnName": "slotIndex",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "start",
|
|
||||||
"columnName": "start",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "end",
|
|
||||||
"columnName": "end",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lessonIndex",
|
|
||||||
"columnName": "lessonIndex",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "raw",
|
|
||||||
"columnName": "raw",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "room",
|
|
||||||
"columnName": "room",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "groupName",
|
|
||||||
"columnName": "groupName",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "subgroup",
|
|
||||||
"columnName": "subgroup",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "subject",
|
|
||||||
"columnName": "subject",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "type",
|
|
||||||
"columnName": "type",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "teacher",
|
|
||||||
"columnName": "teacher",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_lessons_dayId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"dayId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_lessons_date",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"date"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_lessons_subject",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"subject"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_lessons_room",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"room"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "days",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"dayId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "preferences",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "key",
|
|
||||||
"columnName": "key",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "value",
|
|
||||||
"columnName": "value",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"key"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "departments",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`departmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`departmentId`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "departmentId",
|
|
||||||
"columnName": "departmentId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "faculty",
|
|
||||||
"columnName": "faculty",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncedAt",
|
|
||||||
"columnName": "lastSyncedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"departmentId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "department_periods",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "departmentId",
|
|
||||||
"columnName": "departmentId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "season",
|
|
||||||
"columnName": "season",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "weekStart",
|
|
||||||
"columnName": "weekStart",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "weekEnd",
|
|
||||||
"columnName": "weekEnd",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_department_periods_departmentId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"departmentId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_department_periods_departmentId` ON `${TABLE_NAME}` (`departmentId`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "departments",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"departmentId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"departmentId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "teacher_schedules",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "departmentId",
|
|
||||||
"columnName": "departmentId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "season",
|
|
||||||
"columnName": "season",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "weekStart",
|
|
||||||
"columnName": "weekStart",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "weekEnd",
|
|
||||||
"columnName": "weekEnd",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncedAt",
|
|
||||||
"columnName": "lastSyncedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_teacher_schedules_departmentId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"departmentId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId` ON `${TABLE_NAME}` (`departmentId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_teacher_schedules_departmentId_season_weekStart",
|
|
||||||
"unique": true,
|
|
||||||
"columnNames": [
|
|
||||||
"departmentId",
|
|
||||||
"season",
|
|
||||||
"weekStart"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId_season_weekStart` ON `${TABLE_NAME}` (`departmentId`, `season`, `weekStart`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "departments",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"departmentId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"departmentId"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "teacher_days",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `teacher_schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "scheduleId",
|
|
||||||
"columnName": "scheduleId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "date",
|
|
||||||
"columnName": "date",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_teacher_days_scheduleId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"scheduleId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_teacher_days_date",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"date"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_days_date` ON `${TABLE_NAME}` (`date`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "teacher_schedules",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"scheduleId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "teacher_lessons",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT NOT NULL, FOREIGN KEY(`dayId`) REFERENCES `teacher_days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "dayId",
|
|
||||||
"columnName": "dayId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "date",
|
|
||||||
"columnName": "date",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "slotIndex",
|
|
||||||
"columnName": "slotIndex",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "start",
|
|
||||||
"columnName": "start",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "end",
|
|
||||||
"columnName": "end",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lessonIndex",
|
|
||||||
"columnName": "lessonIndex",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "raw",
|
|
||||||
"columnName": "raw",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "room",
|
|
||||||
"columnName": "room",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "groupName",
|
|
||||||
"columnName": "groupName",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "subgroup",
|
|
||||||
"columnName": "subgroup",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "subject",
|
|
||||||
"columnName": "subject",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "type",
|
|
||||||
"columnName": "type",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "teacher",
|
|
||||||
"columnName": "teacher",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_teacher_lessons_dayId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"dayId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_teacher_lessons_teacher",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"teacher"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_teacher` ON `${TABLE_NAME}` (`teacher`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_teacher_lessons_room",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"room"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_room` ON `${TABLE_NAME}` (`room`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_teacher_lessons_subject",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"subject"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "teacher_days",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"dayId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"setupQueries": [
|
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '38abab3f8c5b39625b6a4de9d58968ca')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
Факультет автоматики и вычислительной техники (ОРУ)
|
||||||
|
Группа: ИВТб-3304-05-00
|
||||||
|
|
||||||
|
Понедельник (14.09.26): <пусто, пар нет>
|
||||||
|
Вторник (15.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - ИВТб-3304-05-00 Объектно-ориентированный анализ и проектирование Лекция Преподаватель -.П. 2-202; ИВТб-3304-05-00 Разработка Web-приложений Лекция Преподаватель -.П. 1-236
|
||||||
|
11:45-13:15 - ИВТб-3304-05-00 Инструментальные средства проектирования компонентов вычислительной техники Лекция Семеновых В.И. 2-201; ИВТб-3304-05-00 Предметно-ориентированные автоматизированные информационные системы Лекция Прозорова Ю.А. 1-313; ИВТб-3304-05-00 Сценарное и функциональное программирование Лекция Преподаватель -.П. 1-311
|
||||||
|
14:00-15:30 - ИВТб-3304-05-00 Инструментальные средства проектирования компонентов вычислительной техники Лабораторная работа Семеновых В.И. 2-201; ИВТб-3304-05-00 Предметно-ориентированные автоматизированные информационные системы Лабораторная работа Прозорова Ю.А. 1-236; ИВТб-3304-05-00 Сценарное и функциональное программирование Лабораторная работа Преподаватель -.П. 1-114
|
||||||
|
15:45-17:15 - ИВТб-3304-05-00 Инструментальные средства проектирования компонентов вычислительной техники Лабораторная работа Семеновых В.И. 2-201; ИВТб-3304-05-00 Предметно-ориентированные автоматизированные информационные системы Лабораторная работа Прозорова Ю.А. 1-236; ИВТб-3304-05-00 Сценарное и функциональное программирование Лабораторная работа Преподаватель -.П. 1-114
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Среда (16.09.26): 08:20-09:50 - Введение в архитектуру ЭВМ Лекция Страбыкин Д.А. 1-242
|
||||||
|
10:00-11:30 - Теория компиляторов и формальные грамматики Лекция Мельцов В.Ю. 1-242
|
||||||
|
11:45-13:15 - <пусто>
|
||||||
|
14:00-15:30 - <пусто>
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Четверг (17.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - <пусто>
|
||||||
|
11:45-13:15 - ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 131) Практическое занятие Трушкова Е.Л. 9-115; ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 039) Практическое занятие Салопин О.М. 9-301 ИВТб-3304-05-00, 02 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 039) Практическое занятие Салопин О.М. 9-301; ИВТб-3304-05-00, 02 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (21 285) Практическое занятие Хвостенко С.Ю. 9-209.; ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 124) Практическое занятие Пластинина В.Б. 9-208
|
||||||
|
14:00-15:30 - Архитектура операционных систем и сервисное программное обеспечение Лекция Преподаватель -.П. 1-439
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Пятница (18.09.26): 08:20-09:50 - ИВТб-3304-05-00, 01 подгруппа Введение в архитектуру ЭВМ Лабораторная работа Страбыкин Д.А. 1-117; ИВТб-3304-05-00, 02 подгруппа Введение в архитектуру ЭВМ Лабораторная работа Страбыкин Д.А. 1-117_
|
||||||
|
10:00-11:30 - ИВТб-3304-05-00, 01 подгруппа Введение в архитектуру ЭВМ Лабораторная работа Страбыкин Д.А. 1-117; ИВТб-3304-05-00, 02 подгруппа Введение в архитектуру ЭВМ Лабораторная работа Страбыкин Д.А. 1-117_
|
||||||
|
11:45-13:15 - Введение в архитектуру ЭВМ Практическое занятие Страбыкин Д.А. 2-207
|
||||||
|
14:00-15:30 - Философия Практическое занятие Коротышева Н.Н. 2-407
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Суббота (19.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - <пусто>
|
||||||
|
11:45-13:15 - <пусто>
|
||||||
|
14:00-15:30 - <пусто>
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Воскресенье (20.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - <пусто>
|
||||||
|
11:45-13:15 - <пусто>
|
||||||
|
14:00-15:30 - <пусто>
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Понедельник (21.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - ИВТб-3304-05-00, 01 подгруппа Теория компиляторов и формальные грамматики Лабораторная работа Караваева О.В. 2-308; ИВТб-3304-05-00, 02 подгруппа Теория компиляторов и формальные грамматики Лабораторная работа Караваева О.В. 2-308
|
||||||
|
11:45-13:15 - ИВТб-3304-05-00, 01 подгруппа Теория компиляторов и формальные грамматики Лабораторная работа Караваева О.В. 2-308; ИВТб-3304-05-00, 02 подгруппа Теория компиляторов и формальные грамматики Лабораторная работа Караваева О.В. 2-308_
|
||||||
|
14:00-15:30 - <пусто>
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Вторник (22.09.26): 08:20-09:50 - Объектно-ориентированный анализ и проектирование Практическое занятие Шмакова Н.А. 2-202
|
||||||
|
10:00-11:30 - ИВТб-3304-05-00 Инструментальные средства проектирования компонентов вычислительной техники Лекция Семеновых В.И. 2-201; ИВТб-3304-05-00 Предметно-ориентированные автоматизированные информационные системы Лекция Прозорова Ю.А. 1-313; ИВТб-3304-05-00 Сценарное и функциональное программирование Лекция Преподаватель -.П. 1-311
|
||||||
|
11:45-13:15 - ИВТб-3304-05-00 Объектно-ориентированный анализ и проектирование Лекция Преподаватель -.П. 2-202; ИВТб-3304-05-00 Проектирование систем на ПЛИС Лекция Крутиков А.К. 1-313; ИВТб-3304-05-00 Разработка Web-приложений Лекция Преподаватель -.П. 1-236
|
||||||
|
14:00-15:30 - ИВТб-3304-05-00 Проектирование систем на ПЛИС Практическое занятие Крутиков А.К. 1-109; ИВТб-3304-05-00 Разработка Web-приложений Практическое занятие Пащенко Д.Э. 1-116
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Среда (23.09.26): 08:20-09:50 - Теория компиляторов и формальные грамматики Практическое занятие Караваева О.В. 2-201
|
||||||
|
10:00-11:30 - Введение в архитектуру ЭВМ Лекция Страбыкин Д.А. 1-242
|
||||||
|
11:45-13:15 - Архитектура операционных систем и сервисное программное обеспечение Лекция Преподаватель -.П. 1-242
|
||||||
|
14:00-15:30 - <пусто>
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Четверг (24.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - <пусто>
|
||||||
|
11:45-13:15 - ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 131) Практическое занятие Трушкова Е.Л. 9-115; ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 039) Практическое занятие Салопин О.М. 9-301 ИВТб-3304-05-00, 02 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 039) Практическое занятие Салопин О.М. 9-301; ИВТб-3304-05-00, 02 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (21 285) Практическое занятие Хвостенко С.Ю. 9-101; ИВТб-3304-05-00, 01 подгруппа Элективные дисциплины (модули) по физической культуре и спорту Практическое занятие (11 124) Практическое занятие Пластинина В.Б. 9-208
|
||||||
|
14:00-15:30 - ИВТб-3304-05-00 Объектно-ориентированный анализ и проектирование Лабораторная работа Шмакова Н.А. 2-202; ИВТб-3304-05-00 Проектирование систем на ПЛИС Лабораторная работа Крутиков А.К. 1-239; ИВТб-3304-05-00 Разработка Web-приложений Лабораторная работа Пащенко Д.Э. 1-109
|
||||||
|
15:45-17:15 - ИВТб-3304-05-00 Объектно-ориентированный анализ и проектирование Лабораторная работа Шмакова Н.А. 2-202; ИВТб-3304-05-00 Проектирование систем на ПЛИС Лабораторная работа Крутиков А.К. 1-239; ИВТб-3304-05-00 Разработка Web-приложений Лабораторная работа Пащенко Д.Э. 1-109
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Пятница (25.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - <пусто>
|
||||||
|
11:45-13:15 - <пусто>
|
||||||
|
14:00-15:30 - Теория компиляторов и формальные грамматики Лекция Мельцов В.Ю. 1-242
|
||||||
|
15:45-17:15 - Архитектура операционных систем и сервисное программное обеспечение Лабораторная работа Караваева О.В. 2-201
|
||||||
|
17:20-18:50 - Архитектура операционных систем и сервисное программное обеспечение Лабораторная работа Караваева О.В. 2-201
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Суббота (26.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - <пусто>
|
||||||
|
11:45-13:15 - <пусто>
|
||||||
|
14:00-15:30 - <пусто>
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
|
Воскресенье (27.09.26): 08:20-09:50 - <пусто>
|
||||||
|
10:00-11:30 - <пусто>
|
||||||
|
11:45-13:15 - <пусто>
|
||||||
|
14:00-15:30 - <пусто>
|
||||||
|
15:45-17:15 - <пусто>
|
||||||
|
17:20-18:50 - <пусто>
|
||||||
|
18:55-20:25 - <пусто>
|
||||||
Binary file not shown.
@@ -1,24 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package agzam.vyatsu.data.remote.pdf
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PdfDumpTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dumpParsedSchedule() {
|
||||||
|
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
|
val bytes = instrumentation.context.assets.open("schedule.pdf").use { it.readBytes() }
|
||||||
|
|
||||||
|
val days = PdfScheduleParser.parse(bytes)
|
||||||
|
|
||||||
|
val target = instrumentation.targetContext.getExternalFilesDir(null)!!.resolve("parsed.txt")
|
||||||
|
target.writeText(days.joinToString(separator = "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package agzam.vyatsu.data.remote.pdf
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Day
|
||||||
|
import agzam.vyatsu.domain.model.Lesson
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class PdfScheduleParserTest {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val days: List<Day> by lazy {
|
||||||
|
val bytes = InstrumentationRegistry.getInstrumentation().context.assets
|
||||||
|
.open("schedule.pdf")
|
||||||
|
.use { it.readBytes() }
|
||||||
|
PdfScheduleParser.parse(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun day(date: String): Day = days.first { it.date == LocalDate.parse(date) }
|
||||||
|
|
||||||
|
private fun lessons(date: String, start: String): List<Lesson> =
|
||||||
|
day(date).intervals
|
||||||
|
.first { it.start == LocalTime.parse(start) }
|
||||||
|
.lessons
|
||||||
|
.filter { it.raw.isNotBlank() }
|
||||||
|
|
||||||
|
private fun allLessons(date: String): List<Lesson> =
|
||||||
|
day(date).intervals.flatMap { it.lessons }.filter { it.raw.isNotBlank() }
|
||||||
|
|
||||||
|
private fun Lesson.canonical(): String =
|
||||||
|
listOf(subject, type.name, room, teacher, subgroup).joinToString("|")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesWholeTwoWeekPeriod() {
|
||||||
|
val expectedDates = (0..13L).map { LocalDate.of(2026, 9, 14).plusDays(it) }
|
||||||
|
|
||||||
|
assertEquals(expectedDates, days.map { it.date })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyDayHasSevenStandardIntervals() {
|
||||||
|
val expectedTimes = listOf(
|
||||||
|
LocalTime.of(8, 20) to LocalTime.of(9, 50),
|
||||||
|
LocalTime.of(10, 0) to LocalTime.of(11, 30),
|
||||||
|
LocalTime.of(11, 45) to LocalTime.of(13, 15),
|
||||||
|
LocalTime.of(14, 0) to LocalTime.of(15, 30),
|
||||||
|
LocalTime.of(15, 45) to LocalTime.of(17, 15),
|
||||||
|
LocalTime.of(17, 20) to LocalTime.of(18, 50),
|
||||||
|
LocalTime.of(18, 55) to LocalTime.of(20, 25)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (day in days) {
|
||||||
|
assertEquals(day.date.toString(), expectedTimes, day.intervals.map { it.start to it.end })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyDaysHaveNoLessons() {
|
||||||
|
val emptyDays = listOf("2026-09-14", "2026-09-19", "2026-09-20", "2026-09-26", "2026-09-27")
|
||||||
|
|
||||||
|
for (date in emptyDays) {
|
||||||
|
assertEquals(date, emptyList<Lesson>(), allLessons(date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesTwoLecturesInOneSlot() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"Объектно-ориентированный анализ и проектирование|LECTURE|2-202|Преподаватель -.П.|null",
|
||||||
|
"Разработка Web-приложений|LECTURE|1-236|Преподаватель -.П.|null"
|
||||||
|
),
|
||||||
|
lessons("2026-09-15", "10:00").map { it.canonical() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesThreeLecturesInOneSlot() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"Инструментальные средства проектирования компонентов вычислительной техники|LECTURE|2-201|Семеновых В.И.|null",
|
||||||
|
"Предметно-ориентированные автоматизированные информационные системы|LECTURE|1-313|Прозорова Ю.А.|null",
|
||||||
|
"Сценарное и функциональное программирование|LECTURE|1-311|Преподаватель -.П.|null"
|
||||||
|
),
|
||||||
|
lessons("2026-09-15", "11:45").map { it.canonical() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesLaboratoryWork() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"Инструментальные средства проектирования компонентов вычислительной техники|LABORATORY|2-201|Семеновых В.И.|null",
|
||||||
|
"Предметно-ориентированные автоматизированные информационные системы|LABORATORY|1-236|Прозорова Ю.А.|null",
|
||||||
|
"Сценарное и функциональное программирование|LABORATORY|1-114|Преподаватель -.П.|null"
|
||||||
|
),
|
||||||
|
lessons("2026-09-15", "14:00").map { it.canonical() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesWednesdayLectures() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("Введение в архитектуру ЭВМ|LECTURE|1-242|Страбыкин Д.А.|null"),
|
||||||
|
lessons("2026-09-16", "08:20").map { it.canonical() }
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("Теория компиляторов и формальные грамматики|LECTURE|1-242|Мельцов В.Ю.|null"),
|
||||||
|
lessons("2026-09-16", "10:00").map { it.canonical() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesPhysicalEducationSubgroups() {
|
||||||
|
val parsed = lessons("2026-09-17", "11:45").filter { it.room != null }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"Элективные дисциплины (модули) по физической культуре и спорту|PRACTICE|9-115|Трушкова Е.Л.|01 подгруппа",
|
||||||
|
"Элективные дисциплины (модули) по физической культуре и спорту|PRACTICE|9-301|Салопин О.М.|01 подгруппа",
|
||||||
|
"Элективные дисциплины (модули) по физической культуре и спорту|PRACTICE|9-301|Салопин О.М.|02 подгруппа"
|
||||||
|
),
|
||||||
|
parsed.map { it.canonical() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsRawTextWhenRoomHasTrailingPunctuation() {
|
||||||
|
val unparsed = lessons("2026-09-17", "11:45").filter { it.room == null }
|
||||||
|
|
||||||
|
assertEquals(2, unparsed.size)
|
||||||
|
assertTrue(unparsed.any { it.raw.contains("9-209.") })
|
||||||
|
assertTrue(unparsed.any { it.raw.contains("9-208__") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesFridaySubgroupLaboratories() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"Введение в архитектуру ЭВМ|LABORATORY|1-117|Страбыкин Д.А.|01 подгруппа",
|
||||||
|
"Введение в архитектуру ЭВМ|LABORATORY|1-117_|Страбыкин Д.А.|02 подгруппа"
|
||||||
|
),
|
||||||
|
lessons("2026-09-18", "08:20").map { it.canonical() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesSecondWeekMondayLaboratories() {
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
"Теория компиляторов и формальные грамматики|LABORATORY|2-308|Караваева О.В.|01 подгруппа",
|
||||||
|
"Теория компиляторов и формальные грамматики|LABORATORY|2-308_|Караваева О.В.|02 подгруппа"
|
||||||
|
),
|
||||||
|
lessons("2026-09-21", "10:00").map { it.canonical() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@drawable/icon"
|
android:icon="@drawable/icon"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.VyatsuSchedule">
|
android:theme="@style/Theme.VyatsuSchedule">
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
package agzam.vyatsu
|
package agzam.vyatsu
|
||||||
|
|
||||||
import agzam.vyatsu.di.AppContainer
|
import agzam.vyatsu.di.AppContainer
|
||||||
|
import agzam.vyatsu.ui.groups.GroupPickerScreen
|
||||||
|
import agzam.vyatsu.ui.groups.GroupPickerViewModel
|
||||||
import agzam.vyatsu.ui.home.HomeScreen
|
import agzam.vyatsu.ui.home.HomeScreen
|
||||||
import agzam.vyatsu.ui.picker.PickerScreen
|
|
||||||
import agzam.vyatsu.ui.picker.PickerViewModel
|
|
||||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
import agzam.vyatsu.ui.settings.UpdateViewModel
|
||||||
import agzam.vyatsu.ui.teacher.TeacherScheduleViewModel
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -24,11 +23,9 @@ fun AppRoot() {
|
|||||||
val container = app.container
|
val container = app.container
|
||||||
|
|
||||||
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
|
||||||
val teacherScheduleViewModel: TeacherScheduleViewModel = viewModel(factory = teacherScheduleFactory(container))
|
|
||||||
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
|
||||||
|
|
||||||
val state by scheduleViewModel.uiState.collectAsState()
|
val state by scheduleViewModel.uiState.collectAsState()
|
||||||
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
|
|
||||||
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
|
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
|
||||||
|
|
||||||
if (showFirstRunDialog) {
|
if (showFirstRunDialog) {
|
||||||
@@ -54,21 +51,18 @@ fun AppRoot() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.noGroup && teacherState.noTeacher) {
|
if (state.noGroup) {
|
||||||
val pickerViewModel: PickerViewModel = viewModel(factory = pickerFactory(container))
|
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
|
||||||
PickerScreen(
|
GroupPickerScreen(
|
||||||
viewModel = pickerViewModel,
|
viewModel = groupPickerViewModel,
|
||||||
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) },
|
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
|
||||||
onTeacherPicked = { teacher -> teacherScheduleViewModel.selectTeacher(teacher.name) }
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
HomeScreen(
|
HomeScreen(
|
||||||
scheduleViewModel = scheduleViewModel,
|
scheduleViewModel = scheduleViewModel,
|
||||||
teacherScheduleViewModel = teacherScheduleViewModel,
|
|
||||||
updateViewModel = updateViewModel,
|
updateViewModel = updateViewModel,
|
||||||
onSwitchGroup = { scheduleViewModel.switchGroup() },
|
onSwitchGroup = { scheduleViewModel.switchGroup() }
|
||||||
onSwitchTeacher = { teacherScheduleViewModel.switchTeacher() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,13 +72,5 @@ private class SimpleFactory<T : ViewModel>(private val create: () -> T) : ViewMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { ScheduleViewModel(c.scheduleRepository, c.preferencesRepository, c.groupRepository) }
|
private fun 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 groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) }
|
||||||
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) }
|
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package agzam.vyatsu.data.local
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
import androidx.room.TypeConverters
|
||||||
import androidx.room.migration.Migration
|
|
||||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -13,14 +11,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
ScheduleEntity::class,
|
ScheduleEntity::class,
|
||||||
DayEntity::class,
|
DayEntity::class,
|
||||||
LessonEntity::class,
|
LessonEntity::class,
|
||||||
PreferenceEntity::class,
|
PreferenceEntity::class
|
||||||
DepartmentEntity::class,
|
|
||||||
DepartmentPeriodEntity::class,
|
|
||||||
TeacherScheduleEntity::class,
|
|
||||||
TeacherDayEntity::class,
|
|
||||||
TeacherLessonEntity::class
|
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 3,
|
||||||
exportSchema = true
|
exportSchema = true
|
||||||
)
|
)
|
||||||
@TypeConverters(Converters::class)
|
@TypeConverters(Converters::class)
|
||||||
@@ -28,91 +21,4 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun groupDao(): GroupDao
|
abstract fun groupDao(): GroupDao
|
||||||
abstract fun scheduleDao(): ScheduleDao
|
abstract fun scheduleDao(): ScheduleDao
|
||||||
abstract fun preferencesDao(): PreferencesDao
|
abstract fun preferencesDao(): PreferencesDao
|
||||||
abstract fun teacherDao(): TeacherDao
|
|
||||||
abstract fun teacherScheduleDao(): TeacherScheduleDao
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
|
||||||
override fun migrate(db: SupportSQLiteDatabase) {
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE TABLE IF NOT EXISTS `departments` (" +
|
|
||||||
"`departmentId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
|
|
||||||
"`faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, " +
|
|
||||||
"PRIMARY KEY(`departmentId`))"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE TABLE IF NOT EXISTS `department_periods` (" +
|
|
||||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
|
||||||
"`departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, " +
|
|
||||||
"`weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, " +
|
|
||||||
"FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) " +
|
|
||||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE INDEX IF NOT EXISTS `index_department_periods_departmentId` " +
|
|
||||||
"ON `department_periods` (`departmentId`)"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE TABLE IF NOT EXISTS `teacher_schedules` (" +
|
|
||||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
|
||||||
"`departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, " +
|
|
||||||
"`weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, " +
|
|
||||||
"`lastSyncedAt` INTEGER NOT NULL, " +
|
|
||||||
"FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) " +
|
|
||||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId` " +
|
|
||||||
"ON `teacher_schedules` (`departmentId`)"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE UNIQUE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId_season_weekStart` " +
|
|
||||||
"ON `teacher_schedules` (`departmentId`, `season`, `weekStart`)"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE TABLE IF NOT EXISTS `teacher_days` (" +
|
|
||||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
|
||||||
"`scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, " +
|
|
||||||
"FOREIGN KEY(`scheduleId`) REFERENCES `teacher_schedules`(`id`) " +
|
|
||||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE INDEX IF NOT EXISTS `index_teacher_days_scheduleId` " +
|
|
||||||
"ON `teacher_days` (`scheduleId`)"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE INDEX IF NOT EXISTS `index_teacher_days_date` " +
|
|
||||||
"ON `teacher_days` (`date`)"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE TABLE IF NOT EXISTS `teacher_lessons` (" +
|
|
||||||
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
|
|
||||||
"`dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, " +
|
|
||||||
"`slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, " +
|
|
||||||
"`end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, " +
|
|
||||||
"`raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, " +
|
|
||||||
"`subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, " +
|
|
||||||
"`teacher` TEXT NOT NULL, " +
|
|
||||||
"FOREIGN KEY(`dayId`) REFERENCES `teacher_days`(`id`) " +
|
|
||||||
"ON UPDATE NO ACTION ON DELETE CASCADE )"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_dayId` " +
|
|
||||||
"ON `teacher_lessons` (`dayId`)"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_teacher` " +
|
|
||||||
"ON `teacher_lessons` (`teacher`)"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_room` " +
|
|
||||||
"ON `teacher_lessons` (`room`)"
|
|
||||||
)
|
|
||||||
db.execSQL(
|
|
||||||
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_subject` " +
|
|
||||||
"ON `teacher_lessons` (`subject`)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
@Entity(tableName = "departments")
|
|
||||||
data class DepartmentEntity(
|
|
||||||
@PrimaryKey val departmentId: String,
|
|
||||||
val name: String,
|
|
||||||
val faculty: String,
|
|
||||||
val lastSyncedAt: Long
|
|
||||||
)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Season
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.Index
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
// Все опубликованные на сайте 2-недельные периоды кафедры, включая ещё не начавшиеся.
|
|
||||||
@Entity(
|
|
||||||
tableName = "department_periods",
|
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = DepartmentEntity::class,
|
|
||||||
parentColumns = ["departmentId"],
|
|
||||||
childColumns = ["departmentId"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
)
|
|
||||||
],
|
|
||||||
indices = [Index("departmentId")]
|
|
||||||
)
|
|
||||||
data class DepartmentPeriodEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|
||||||
val departmentId: String,
|
|
||||||
val season: Season,
|
|
||||||
val weekStart: Long,
|
|
||||||
val weekEnd: Long
|
|
||||||
)
|
|
||||||
@@ -28,36 +28,6 @@ interface PreferencesDao {
|
|||||||
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
|
||||||
suspend fun setSelectedDayDate(date: String)
|
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'")
|
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateEnabled'")
|
||||||
suspend fun autoUpdateEnabled(): String?
|
suspend fun autoUpdateEnabled(): String?
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface TeacherDao {
|
|
||||||
@Query("SELECT * FROM departments ORDER BY name")
|
|
||||||
fun observeDepartments(): Flow<List<DepartmentEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM departments WHERE departmentId = :id")
|
|
||||||
suspend fun getDepartment(id: String): DepartmentEntity?
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertAll(departments: List<DepartmentEntity>)
|
|
||||||
|
|
||||||
@Query("SELECT MAX(lastSyncedAt) FROM departments")
|
|
||||||
suspend fun maxSyncedAt(): Long?
|
|
||||||
|
|
||||||
@Query("SELECT DISTINCT faculty FROM departments ORDER BY faculty")
|
|
||||||
suspend fun getFaculties(): List<String>
|
|
||||||
|
|
||||||
@Query("DELETE FROM department_periods")
|
|
||||||
suspend fun deleteAllPeriods()
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertPeriods(periods: List<DepartmentPeriodEntity>)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT * FROM department_periods WHERE weekStart <= :epochDay AND weekEnd >= :epochDay ORDER BY departmentId"
|
|
||||||
)
|
|
||||||
suspend fun getPeriodsForDate(epochDay: Long): List<DepartmentPeriodEntity>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM department_periods WHERE departmentId = :departmentId ORDER BY weekStart")
|
|
||||||
suspend fun getPeriodsForDepartment(departmentId: String): List<DepartmentPeriodEntity>
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.Index
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "teacher_days",
|
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = TeacherScheduleEntity::class,
|
|
||||||
parentColumns = ["id"],
|
|
||||||
childColumns = ["scheduleId"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
)
|
|
||||||
],
|
|
||||||
indices = [
|
|
||||||
Index("scheduleId"),
|
|
||||||
Index("date")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
data class TeacherDayEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|
||||||
val scheduleId: Long,
|
|
||||||
val date: Long
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.LessonType
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.Index
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "teacher_lessons",
|
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = TeacherDayEntity::class,
|
|
||||||
parentColumns = ["id"],
|
|
||||||
childColumns = ["dayId"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
)
|
|
||||||
],
|
|
||||||
indices = [
|
|
||||||
Index("dayId"),
|
|
||||||
Index("teacher"),
|
|
||||||
Index("room"),
|
|
||||||
Index("subject")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
data class TeacherLessonEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|
||||||
val dayId: Long,
|
|
||||||
val date: Long,
|
|
||||||
val slotIndex: Int,
|
|
||||||
val start: Int,
|
|
||||||
val end: Int,
|
|
||||||
val lessonIndex: Int,
|
|
||||||
val raw: String,
|
|
||||||
val room: String?,
|
|
||||||
val groupName: String?,
|
|
||||||
val subgroup: String?,
|
|
||||||
val subject: String?,
|
|
||||||
val type: LessonType,
|
|
||||||
val teacher: String
|
|
||||||
)
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import androidx.room.Embedded
|
|
||||||
|
|
||||||
/** Занятие преподавателя + кафедра-источник. Проекция LEFT JOIN, схема Room не меняется. */
|
|
||||||
data class TeacherLessonWithDepartment(
|
|
||||||
@Embedded val lesson: TeacherLessonEntity,
|
|
||||||
val departmentId: String,
|
|
||||||
val departmentName: String?
|
|
||||||
)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Transaction
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface TeacherScheduleDao {
|
|
||||||
|
|
||||||
@Query("SELECT * FROM teacher_schedules ORDER BY weekStart DESC")
|
|
||||||
fun observeSchedules(): Flow<List<TeacherScheduleEntity>>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM teacher_schedules ORDER BY weekStart DESC")
|
|
||||||
suspend fun getSchedules(): List<TeacherScheduleEntity>
|
|
||||||
|
|
||||||
@Query("DELETE FROM teacher_days WHERE scheduleId = :scheduleId")
|
|
||||||
suspend fun deleteDaysForSchedule(scheduleId: Long)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
|
||||||
suspend fun insertSchedule(schedule: TeacherScheduleEntity): Long
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"DELETE FROM teacher_schedules WHERE departmentId = :departmentId AND season = :season AND weekStart = :weekStart"
|
|
||||||
)
|
|
||||||
suspend fun deleteSchedule(departmentId: String, season: String, weekStart: Long)
|
|
||||||
|
|
||||||
@Query("DELETE FROM teacher_schedules WHERE season = :season AND weekStart = :weekStart")
|
|
||||||
suspend fun deletePeriod(season: String, weekStart: Long)
|
|
||||||
|
|
||||||
@Query("DELETE FROM teacher_schedules")
|
|
||||||
suspend fun deleteAll()
|
|
||||||
|
|
||||||
@Query("SELECT lastSyncedAt FROM teacher_schedules WHERE departmentId = :departmentId AND season = :season AND weekStart = :weekStart")
|
|
||||||
suspend fun lastSyncedAt(departmentId: String, season: String, weekStart: Long): Long?
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertDays(days: List<TeacherDayEntity>): List<Long>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertLessons(lessons: List<TeacherLessonEntity>)
|
|
||||||
|
|
||||||
@Transaction
|
|
||||||
suspend fun replaceSchedule(schedule: TeacherScheduleEntity, days: List<TeacherDayEntity>, lessons: List<TeacherLessonEntity>) {
|
|
||||||
deleteSchedule(schedule.departmentId, schedule.season.name, schedule.weekStart)
|
|
||||||
val scheduleId = insertSchedule(schedule)
|
|
||||||
val daysWithScheduleId = days.map { it.copy(scheduleId = scheduleId) }
|
|
||||||
deleteDaysForSchedule(scheduleId)
|
|
||||||
val dayIds = insertDays(daysWithScheduleId)
|
|
||||||
val dayIdByDate = daysWithScheduleId.zip(dayIds).associate { (day, id) -> day.date to id }
|
|
||||||
insertLessons(lessons.map { lesson -> lesson.copy(dayId = dayIdByDate.getValue(lesson.date)) })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT teacher_days.* FROM teacher_days INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
|
|
||||||
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
|
|
||||||
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher_days.date"
|
|
||||||
)
|
|
||||||
fun observeDaysForPeriod(season: String, weekStart: Long): Flow<List<TeacherDayEntity>>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT teacher_lessons.*, teacher_schedules.departmentId AS departmentId, departments.name AS departmentName " +
|
|
||||||
"FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
|
|
||||||
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
|
|
||||||
"LEFT JOIN departments ON departments.departmentId = teacher_schedules.departmentId " +
|
|
||||||
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
|
|
||||||
"AND teacher_schedules.weekEnd >= :weekStart " +
|
|
||||||
"ORDER BY teacher_lessons.date, teacher_lessons.slotIndex, teacher_lessons.lessonIndex"
|
|
||||||
)
|
|
||||||
fun observeLessonsForPeriod(season: String, weekStart: Long): Flow<List<TeacherLessonWithDepartment>>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT DISTINCT teacher FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
|
|
||||||
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
|
|
||||||
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
|
|
||||||
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher"
|
|
||||||
)
|
|
||||||
fun observeDistinctTeachers(season: String, weekStart: Long): Flow<List<String>>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season " +
|
|
||||||
"AND weekStart <= :weekStart AND weekEnd >= :weekStart)"
|
|
||||||
)
|
|
||||||
suspend fun hasSchedulesForPeriod(season: String, weekStart: Long): Boolean
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package agzam.vyatsu.data.local
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Season
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.Index
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "teacher_schedules",
|
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = DepartmentEntity::class,
|
|
||||||
parentColumns = ["departmentId"],
|
|
||||||
childColumns = ["departmentId"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
)
|
|
||||||
],
|
|
||||||
indices = [
|
|
||||||
Index("departmentId"),
|
|
||||||
Index(value = ["departmentId", "season", "weekStart"], unique = true)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
data class TeacherScheduleEntity(
|
|
||||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
|
||||||
val departmentId: String,
|
|
||||||
val season: Season,
|
|
||||||
val weekStart: Long,
|
|
||||||
val weekEnd: Long,
|
|
||||||
val lastSyncedAt: Long
|
|
||||||
)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package agzam.vyatsu.data.remote
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Справочник сокращённых названий факультетов ВятГУ.
|
|
||||||
* Вынесен из GroupsParser, так как TeachersParser использует те же сокращения.
|
|
||||||
*/
|
|
||||||
object FacultyNames {
|
|
||||||
|
|
||||||
private val parenthesized = Regex("""\(.*?\)\s?""")
|
|
||||||
|
|
||||||
private val shorthands = mapOf(
|
|
||||||
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
|
||||||
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
|
||||||
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
|
|
||||||
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
|
|
||||||
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
|
|
||||||
"Факультет лингвистики (ОРУ)" to "ФЛ",
|
|
||||||
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
|
|
||||||
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
|
|
||||||
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
|
|
||||||
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
|
|
||||||
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
|
|
||||||
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
|
|
||||||
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
|
|
||||||
"Электротехнический факультет (ОРУ)" to "ЭТФ",
|
|
||||||
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
|
|
||||||
)
|
|
||||||
|
|
||||||
fun shortname(name: String): String {
|
|
||||||
val stripped = removeParenthesized(name)
|
|
||||||
val sh = shorthands[name]
|
|
||||||
return if (sh != null) "$sh ($stripped)" else stripped
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
|
|
||||||
}
|
|
||||||
@@ -14,11 +14,30 @@ object GroupsParser {
|
|||||||
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
|
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 val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
|
||||||
|
private val parenthesized = Regex("""\(.*?\)\s?""")
|
||||||
|
|
||||||
private const val CHUNK_SIZE = 8192
|
private const val CHUNK_SIZE = 8192
|
||||||
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
||||||
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
|
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
|
||||||
|
|
||||||
|
private val shorthands = mapOf(
|
||||||
|
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
|
||||||
|
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
|
||||||
|
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
|
||||||
|
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
|
||||||
|
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
|
||||||
|
"Факультет лингвистики (ОРУ)" to "ФЛ",
|
||||||
|
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
|
||||||
|
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
|
||||||
|
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
|
||||||
|
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
|
||||||
|
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
|
||||||
|
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
|
||||||
|
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
|
||||||
|
"Электротехнический факультет (ОРУ)" to "ЭТФ",
|
||||||
|
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
|
||||||
* разбирает его на лету, не материализуя весь HTML в память.
|
* разбирает его на лету, не материализуя весь HTML в память.
|
||||||
@@ -61,7 +80,8 @@ object GroupsParser {
|
|||||||
else -> minOf(divAt, aAt)
|
else -> minOf(divAt, aAt)
|
||||||
}
|
}
|
||||||
if (tagStart < 0) {
|
if (tagStart < 0) {
|
||||||
pos = keepPartialTagTail(pending, pos)
|
// хвостовой текст без div-тегов — обычный текст страницы
|
||||||
|
pos = pending.length
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (tagStart > pos) {
|
if (tagStart > pos) {
|
||||||
@@ -81,13 +101,13 @@ object GroupsParser {
|
|||||||
val content = pending.substring(tagEnd + 1, closeDiv)
|
val content = pending.substring(tagEnd + 1, closeDiv)
|
||||||
if (isFakName) {
|
if (isFakName) {
|
||||||
val name = content.trim()
|
val name = content.trim()
|
||||||
if (name.isNotEmpty()) faculty = FacultyNames.shortname(name)
|
if (name.isNotEmpty()) faculty = shortname(name)
|
||||||
} else {
|
} else {
|
||||||
val m = grpIdPattern.find(tagText)
|
val m = grpIdPattern.find(tagText)
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
groups += Group(
|
groups += Group(
|
||||||
id = m.groupValues[1].dropLast(1),
|
id = m.groupValues[1].dropLast(1),
|
||||||
name = FacultyNames.removeParenthesized(content),
|
name = removeParenthesized(content),
|
||||||
faculty = faculty
|
faculty = faculty
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -133,22 +153,13 @@ object GroupsParser {
|
|||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun shortname(name: String): String {
|
||||||
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
|
val stripped = removeParenthesized(name)
|
||||||
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
|
val sh = shorthands[name]
|
||||||
*/
|
return if (sh != null) "$sh ($stripped)" else stripped
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ParsedGroups(
|
data class ParsedGroups(
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
package agzam.vyatsu.data.remote
|
|
||||||
|
|
||||||
import agzam.vyatsu.data.remote.pdf.TextPatterns
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
|
||||||
import agzam.vyatsu.domain.model.Lesson
|
|
||||||
import agzam.vyatsu.domain.model.LessonType
|
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
|
||||||
import android.util.Log
|
|
||||||
import org.jsoup.Jsoup
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
object TeacherScheduleParser {
|
|
||||||
|
|
||||||
private const val TAG = "TeacherParserDebug"
|
|
||||||
private val DAY_REGEX = Regex("""^(понедельник|вторник|среда|четверг|пятница|суббота|воскресенье)\s+(\d{2}\.\d{2}\.\d{2})$""")
|
|
||||||
private val INTERVAL_REGEX = Regex("""^(\d{2}:\d{2})-(\d{2}:\d{2})$""")
|
|
||||||
private val GROUP_TOKEN = Regex("""(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}])""")
|
|
||||||
private val SPLIT_BY_GROUP = Regex("""(?=(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}]))""")
|
|
||||||
private val DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yy")
|
|
||||||
|
|
||||||
data class ParsedTeacherSchedule(val teachers: List<String>, val days: List<Day>)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Столбец расписания преподавателя: имя и абсолютный индекс ячейки в шапке.
|
|
||||||
* Индекс нужен для точного выравнивания с рядами данных — в шапке могут
|
|
||||||
* присутствовать пустые колонки-разделители, которые не должны сдвигать
|
|
||||||
* соответствие «преподаватель → столбец».
|
|
||||||
*/
|
|
||||||
private data class Column(val teacher: String, val headerIndex: Int)
|
|
||||||
|
|
||||||
fun parse(html: String): ParsedTeacherSchedule {
|
|
||||||
val doc = Jsoup.parse(html)
|
|
||||||
var headerFound = false
|
|
||||||
var columns: List<Column> = emptyList()
|
|
||||||
var teachers: List<String> = emptyList()
|
|
||||||
val days = mutableListOf<Day>()
|
|
||||||
var currentDate: LocalDate? = null
|
|
||||||
var currentIntervals: MutableList<TimeSlot>? = null
|
|
||||||
|
|
||||||
fun flushDay() {
|
|
||||||
val date = currentDate ?: return
|
|
||||||
val intervals = currentIntervals ?: return
|
|
||||||
if (intervals.isNotEmpty()) days.add(Day(date, intervals.toList()))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (tr in doc.select("tr")) {
|
|
||||||
val tds = tr.children().filter { it.tagName() == "td" }
|
|
||||||
if (tds.size < 2) continue
|
|
||||||
val first = cellText(tds[0])
|
|
||||||
val second = cellText(tds[1])
|
|
||||||
if (!headerFound && first == "День" && second == "Интервал") {
|
|
||||||
columns = tds.mapIndexedNotNull { index, td ->
|
|
||||||
if (index < 2) return@mapIndexedNotNull null
|
|
||||||
val name = cellText(td.selectFirst("span") ?: td)
|
|
||||||
if (name.isEmpty()) null else Column(name, index)
|
|
||||||
}.distinctBy { it.teacher }
|
|
||||||
teachers = columns.map { it.teacher }
|
|
||||||
headerFound = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val dayMatch = DAY_REGEX.matchEntire(first)
|
|
||||||
val intervalMatch = INTERVAL_REGEX.matchEntire(if (dayMatch != null) second else first)
|
|
||||||
if (dayMatch != null) {
|
|
||||||
flushDay()
|
|
||||||
currentDate = LocalDate.parse(dayMatch.groupValues[2], DATE_FMT)
|
|
||||||
currentIntervals = mutableListOf()
|
|
||||||
}
|
|
||||||
if (intervalMatch != null && currentIntervals != null) {
|
|
||||||
parseIntervalRow(tds, intervalMatch, dayMatch != null, columns, currentIntervals!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushDay()
|
|
||||||
Log.d(TAG, "parse: teachers=$teachers days=${days.size}")
|
|
||||||
for (day in days) {
|
|
||||||
Log.d(TAG, " parse ${day.date}: intervals=${day.intervals.size}")
|
|
||||||
for (slot in day.intervals) {
|
|
||||||
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
|
|
||||||
val teachersInSlot = slot.lessons.map { it.teacher }.distinct()
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
" slot ${slot.start}-${slot.end} total=${slot.lessons.size} meltsov=${mel.size} teachers=$teachersInSlot"
|
|
||||||
)
|
|
||||||
mel.forEach { l ->
|
|
||||||
Log.d(TAG, " mel: room=${l.room} group=${l.group} subj=${l.subject} type=${l.type} raw=${l.raw}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ParsedTeacherSchedule(teachers, days)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseIntervalRow(
|
|
||||||
tds: List<Element>,
|
|
||||||
intervalMatch: MatchResult,
|
|
||||||
dayRow: Boolean,
|
|
||||||
columns: List<Column>,
|
|
||||||
intervals: MutableList<TimeSlot>
|
|
||||||
) {
|
|
||||||
val start = LocalTime.parse(intervalMatch.groupValues[1])
|
|
||||||
val end = LocalTime.parse(intervalMatch.groupValues[2])
|
|
||||||
val lessons = mutableListOf<Lesson>()
|
|
||||||
for (column in columns) {
|
|
||||||
// Ряд с днём содержит обе ведущие ячейки (День, Интервал) и совпадает
|
|
||||||
// по индексам с шапкой; ряд только с интервалом пропускает ячейку дня
|
|
||||||
// (rowspan) — его ячейки сдвинуты на одну позицию влево.
|
|
||||||
val dataIndex = if (dayRow) column.headerIndex else column.headerIndex - 1
|
|
||||||
val cell = tds.getOrNull(dataIndex) ?: continue
|
|
||||||
lessons += parseCell(cell, column.teacher)
|
|
||||||
}
|
|
||||||
if (lessons.isNotEmpty()) intervals.add(TimeSlot(start, end, lessons))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseCell(cell: Element, teacher: String): List<Lesson> {
|
|
||||||
val raw = cellText(cell)
|
|
||||||
if (raw.isEmpty()) return emptyList()
|
|
||||||
val room = TextPatterns.ROOM_REGEX.find(raw)?.value
|
|
||||||
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.find(raw)
|
|
||||||
val type = LessonType.fromRaw(typeMatch?.value?.trim() ?: "")
|
|
||||||
val segments = raw.split(SPLIT_BY_GROUP).map { it.trim() }.filter { it.isNotEmpty() }
|
|
||||||
val groupSegments = segments.filter { GROUP_TOKEN.containsMatchIn(it) }
|
|
||||||
if (groupSegments.isEmpty()) {
|
|
||||||
return listOf(
|
|
||||||
Lesson(
|
|
||||||
raw = raw,
|
|
||||||
room = room,
|
|
||||||
subject = subjectOf(raw, room, typeMatch),
|
|
||||||
type = type,
|
|
||||||
teacher = teacher
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val subject = subjectOf(raw, room, typeMatch)
|
|
||||||
return groupSegments.flatMap { segment ->
|
|
||||||
val groups = GROUP_TOKEN.findAll(segment).map { it.value }.distinct().toList()
|
|
||||||
val subgroup = TextPatterns.SUBGROUP_REGEX.find(segment)?.value
|
|
||||||
groups.map { group ->
|
|
||||||
Lesson(
|
|
||||||
raw = segment,
|
|
||||||
room = room,
|
|
||||||
group = group,
|
|
||||||
subgroup = subgroup,
|
|
||||||
subject = subject,
|
|
||||||
type = type,
|
|
||||||
teacher = teacher
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun subjectOf(raw: String, room: String?, typeMatch: MatchResult?): String? {
|
|
||||||
val end = typeMatch?.range?.first ?: GROUP_TOKEN.find(raw)?.range?.first ?: raw.length
|
|
||||||
val prefix = raw.substring(0, end).trim()
|
|
||||||
val stripped = room?.let { prefix.removePrefix(it).trim() } ?: prefix
|
|
||||||
return stripped.ifEmpty { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cellText(el: Element): String = el.text().replace('\u00A0', ' ').trim()
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package agzam.vyatsu.data.remote
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.DateRange
|
|
||||||
import agzam.vyatsu.domain.model.Department
|
|
||||||
import agzam.vyatsu.domain.model.ParsedDepartments
|
|
||||||
import agzam.vyatsu.domain.model.Season
|
|
||||||
import java.io.InputStream
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Стриминговый парсер страницы преподавателей: читает тело ответа по чанкам и
|
|
||||||
* разбирает его на лету, не материализуя весь HTML в память.
|
|
||||||
* Страница teacher.html весит ~2.4MB, поэтому весь документ грузить нельзя.
|
|
||||||
*/
|
|
||||||
object TeachersParser {
|
|
||||||
|
|
||||||
private val fakNameTag = Regex("""<div[^>]*class="fak_name"""")
|
|
||||||
private val fakIdInTag = Regex("""data-fak_id="(\d+)"""")
|
|
||||||
private val kafPeriodTag = Regex("""<div[^>]*class="kafPeriod"""")
|
|
||||||
private val kafIdInTag = Regex("""data-kaf_period_id="(\d+)"""")
|
|
||||||
private val prepodLink = Regex("""href="/reports/schedule/prepod/(\d+)_([12])_(\d{8})_(\d{8})\.html"""")
|
|
||||||
private val tagStrip = Regex("""<[^>]*>""")
|
|
||||||
|
|
||||||
private const val CHUNK_SIZE = 8192
|
|
||||||
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
|
|
||||||
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
|
|
||||||
|
|
||||||
fun parseStream(input: InputStream): ParsedDepartments {
|
|
||||||
val reader = input.bufferedReader(Charsets.UTF_8)
|
|
||||||
val buf = CharArray(CHUNK_SIZE)
|
|
||||||
val pending = StringBuilder(CHUNK_SIZE * 2)
|
|
||||||
val departments = ArrayList<Department>()
|
|
||||||
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
|
|
||||||
var faculty = ""
|
|
||||||
|
|
||||||
fun addRanges(text: String) {
|
|
||||||
for (m in prepodLink.findAll(text)) {
|
|
||||||
val departmentId = m.groupValues[1]
|
|
||||||
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
|
|
||||||
val range = DateRange(m.groupValues[3], m.groupValues[4])
|
|
||||||
ranges.getOrPut(departmentId) {
|
|
||||||
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
|
|
||||||
}.getOrPut(season) { ArrayList() }.add(range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
val n = reader.read(buf)
|
|
||||||
if (n < 0) break
|
|
||||||
pending.append(buf, 0, n)
|
|
||||||
|
|
||||||
var pos = 0
|
|
||||||
while (pos < pending.length) {
|
|
||||||
val divAt = pending.indexOf("<div", pos)
|
|
||||||
val aAt = pending.indexOf("<a", pos)
|
|
||||||
val tagStart = when {
|
|
||||||
divAt < 0 -> aAt
|
|
||||||
aAt < 0 -> divAt
|
|
||||||
else -> minOf(divAt, aAt)
|
|
||||||
}
|
|
||||||
if (tagStart < 0) {
|
|
||||||
pos = keepPartialTagTail(pending, pos)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (tagStart > pos) {
|
|
||||||
addRanges(pending.substring(pos, tagStart))
|
|
||||||
}
|
|
||||||
val tagEnd = findTagEnd(pending, tagStart)
|
|
||||||
if (tagEnd < 0) break
|
|
||||||
val tagText = pending.substring(tagStart, tagEnd + 1)
|
|
||||||
|
|
||||||
val isDiv = tagText.startsWith("<div")
|
|
||||||
val isFakName = isDiv && fakNameTag.containsMatchIn(tagText)
|
|
||||||
val isKafPeriod = isDiv && kafPeriodTag.containsMatchIn(tagText)
|
|
||||||
if (isFakName || isKafPeriod) {
|
|
||||||
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
|
|
||||||
if (closeDiv < 0) break
|
|
||||||
addRanges(tagText)
|
|
||||||
val content = pending.substring(tagEnd + 1, closeDiv)
|
|
||||||
if (isFakName) {
|
|
||||||
val stripped = tagStrip.replace(content, "").trim()
|
|
||||||
if (stripped.isNotEmpty()) faculty = FacultyNames.shortname(stripped)
|
|
||||||
} else {
|
|
||||||
val m = kafIdInTag.find(tagText)
|
|
||||||
if (m != null) {
|
|
||||||
departments += Department(
|
|
||||||
departmentId = m.groupValues[1].dropLast(1),
|
|
||||||
name = tagStrip.replace(content, "").trim(),
|
|
||||||
faculty = faculty
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pos = closeDiv + 6
|
|
||||||
} else {
|
|
||||||
addRanges(tagText)
|
|
||||||
pos = tagEnd + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pos > MAX_SLICE) {
|
|
||||||
pending.delete(0, pos)
|
|
||||||
pos = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (pos > 0 && pos <= pending.length) {
|
|
||||||
pending.delete(0, pos)
|
|
||||||
}
|
|
||||||
if (pending.length > MAX_PENDING) {
|
|
||||||
pending.delete(0, pending.length - MAX_SLICE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ParsedDepartments(departments, ranges)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
|
|
||||||
private fun findTagEnd(s: CharSequence, start: Int): Int {
|
|
||||||
var i = start
|
|
||||||
var quote = 0.toChar()
|
|
||||||
while (i < s.length) {
|
|
||||||
val c = s[i]
|
|
||||||
if (quote != 0.toChar()) {
|
|
||||||
if (c == quote) quote = 0.toChar()
|
|
||||||
} else {
|
|
||||||
when (c) {
|
|
||||||
'"', '\'' -> quote = c
|
|
||||||
'>' -> return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
|
|
||||||
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
|
|
||||||
*/
|
|
||||||
private fun keepPartialTagTail(pending: CharSequence, from: Int): Int {
|
|
||||||
var i = from
|
|
||||||
while (i < pending.length) {
|
|
||||||
val c = pending[i]
|
|
||||||
if (c == '<') {
|
|
||||||
val tail = pending.substring(i, pending.length)
|
|
||||||
if (tail == "<" || tail == "<d" || tail == "<di" || tail == "<a") return i
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return pending.length
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ object VyatsuHttpClient {
|
|||||||
|
|
||||||
suspend fun getText(url: String): String = withContext(Dispatchers.IO) {
|
suspend fun getText(url: String): String = withContext(Dispatchers.IO) {
|
||||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
|
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||||
resp.body!!.string()
|
resp.body!!.string()
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ object VyatsuHttpClient {
|
|||||||
|
|
||||||
suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) {
|
suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) {
|
||||||
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
|
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||||
resp.body!!.bytes()
|
resp.body!!.bytes()
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ object VyatsuHttpClient {
|
|||||||
*/
|
*/
|
||||||
suspend fun openStream(url: String): InputStream = withContext(Dispatchers.IO) {
|
suspend fun openStream(url: String): InputStream = withContext(Dispatchers.IO) {
|
||||||
val resp = client.newCall(Request.Builder().url(url).build()).execute()
|
val resp = client.newCall(Request.Builder().url(url).build()).execute()
|
||||||
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
|
Log.d(TAG, "GET $url -> ${resp.code}")
|
||||||
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
|
||||||
resp.body!!.byteStream()
|
resp.body!!.byteStream()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,4 @@ object VyatsuUrls {
|
|||||||
|
|
||||||
fun groupPdfUrl(groupId: String, season: Season, range: DateRange): String = "$PDF_BASEURL/${groupId}_${season.apiKey}_${range.first}_${range.second}.pdf"
|
fun groupPdfUrl(groupId: String, season: Season, range: DateRange): String = "$PDF_BASEURL/${groupId}_${season.apiKey}_${range.first}_${range.second}.pdf"
|
||||||
|
|
||||||
const val TEACHER_PAGE = "https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/teacher.html"
|
|
||||||
const val PREPOD_BASEURL = "https://www.vyatsu.ru/reports/schedule/prepod"
|
|
||||||
|
|
||||||
fun teacherPrepodUrl(departmentId: String, season: Season, range: DateRange): String = "$PREPOD_BASEURL/${departmentId}_${season.apiKey}_${range.first}_${range.second}.html"
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ object PdfScheduleParser {
|
|||||||
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
|
||||||
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
|
||||||
|
|
||||||
|
private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
|
||||||
|
private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } )
|
||||||
|
private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
fun parse(pdfBytes: ByteArray): List<Day> {
|
fun parse(pdfBytes: ByteArray): List<Day> {
|
||||||
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
|
||||||
val doc = try {
|
val doc = try {
|
||||||
@@ -141,7 +145,7 @@ object PdfScheduleParser {
|
|||||||
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
|
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
|
||||||
var found = 0
|
var found = 0
|
||||||
var currentStart = 0
|
var currentStart = 0
|
||||||
for (roomMatch in TextPatterns.ROOM_REGEX.findAll(content)) {
|
for (roomMatch in ROOM_REGEX.findAll(content)) {
|
||||||
found++
|
found++
|
||||||
|
|
||||||
val raw = content.substring(currentStart, roomMatch.range.last + 1)
|
val raw = content.substring(currentStart, roomMatch.range.last + 1)
|
||||||
@@ -151,7 +155,7 @@ object PdfScheduleParser {
|
|||||||
|
|
||||||
val room = roomMatch.value
|
val room = roomMatch.value
|
||||||
|
|
||||||
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.findAll(raw).toList()
|
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList()
|
||||||
if(typeMatch.isEmpty()) {
|
if(typeMatch.isEmpty()) {
|
||||||
lessons.add(Lesson(
|
lessons.add(Lesson(
|
||||||
raw=raw,
|
raw=raw,
|
||||||
@@ -166,7 +170,7 @@ object PdfScheduleParser {
|
|||||||
Log.d(TAG, "$subject $group")
|
Log.d(TAG, "$subject $group")
|
||||||
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
|
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
|
||||||
subject = subject.substring(group.length + 1).trim()
|
subject = subject.substring(group.length + 1).trim()
|
||||||
val subgroupMatch = TextPatterns.SUBGROUP_REGEX.find(subject)
|
val subgroupMatch = SUBGROUP_REGEX.find(subject)
|
||||||
if(subgroupMatch != null) {
|
if(subgroupMatch != null) {
|
||||||
subgroup = subgroupMatch.value.trim()
|
subgroup = subgroupMatch.value.trim()
|
||||||
subject = subject.removeRange(subgroupMatch.range).trim()
|
subject = subject.removeRange(subgroupMatch.range).trim()
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package agzam.vyatsu.data.remote.pdf
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.LessonType
|
|
||||||
|
|
||||||
object TextPatterns {
|
|
||||||
|
|
||||||
val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
|
|
||||||
|
|
||||||
val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" })
|
|
||||||
|
|
||||||
val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
|
|
||||||
}
|
|
||||||
@@ -14,16 +14,6 @@ class PreferencesRepositoryImpl(
|
|||||||
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
override suspend fun selectedDayDate(): String? = dao.selectedDayDate()
|
||||||
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
|
override suspend fun setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
|
||||||
|
|
||||||
override suspend fun selectedTeacherId(): String? = dao.selectedTeacherId()
|
|
||||||
override suspend fun setSelectedTeacherId(id: String) = dao.setSelectedTeacherId(id)
|
|
||||||
override suspend fun selectedTeacherWeekStart(): String? = dao.selectedTeacherWeekStart()
|
|
||||||
override suspend fun setSelectedTeacherWeekStart(weekStart: String) =
|
|
||||||
dao.setSelectedTeacherWeekStart(weekStart)
|
|
||||||
override suspend fun selectedTeacherDayDate(): String? = dao.selectedTeacherDayDate()
|
|
||||||
override suspend fun setSelectedTeacherDayDate(date: String) =
|
|
||||||
dao.setSelectedTeacherDayDate(date)
|
|
||||||
override suspend fun clearSelectedTeacher() = dao.clearSelectedTeacher()
|
|
||||||
|
|
||||||
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() == "true"
|
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() == "true"
|
||||||
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
|
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
package agzam.vyatsu.data.repository
|
|
||||||
|
|
||||||
import agzam.vyatsu.data.local.DepartmentEntity
|
|
||||||
import agzam.vyatsu.data.local.DepartmentPeriodEntity
|
|
||||||
import agzam.vyatsu.data.local.TeacherDao
|
|
||||||
import agzam.vyatsu.data.remote.TeachersParser
|
|
||||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
|
||||||
import agzam.vyatsu.data.remote.VyatsuUrls
|
|
||||||
import agzam.vyatsu.domain.ScheduleDates
|
|
||||||
import agzam.vyatsu.domain.model.Department
|
|
||||||
import agzam.vyatsu.domain.repository.TeacherRepository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class TeacherRepositoryImpl(
|
|
||||||
private val teacherDao: TeacherDao,
|
|
||||||
) : TeacherRepository {
|
|
||||||
|
|
||||||
override fun observeDepartments(): Flow<List<Department>> =
|
|
||||||
teacherDao.observeDepartments().map { list -> list.map { it.toDomain() } }
|
|
||||||
|
|
||||||
override suspend fun getDepartments(): List<Department> =
|
|
||||||
teacherDao.observeDepartments().first().map { it.toDomain() }
|
|
||||||
|
|
||||||
override suspend fun getFaculties(): List<String> = teacherDao.getFaculties()
|
|
||||||
|
|
||||||
override suspend fun refreshIfStale(): Boolean {
|
|
||||||
val last = teacherDao.maxSyncedAt()
|
|
||||||
if (last != null && now() - last < STALE_WINDOW_MS) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
refresh()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun forceRefresh(): Boolean {
|
|
||||||
refresh()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun refresh() {
|
|
||||||
val parsed = withContext(Dispatchers.IO) {
|
|
||||||
VyatsuHttpClient.openStream(VyatsuUrls.TEACHER_PAGE).use(TeachersParser::parseStream)
|
|
||||||
}
|
|
||||||
val now = now()
|
|
||||||
teacherDao.insertAll(
|
|
||||||
parsed.departments.map { department ->
|
|
||||||
DepartmentEntity(
|
|
||||||
departmentId = department.departmentId,
|
|
||||||
name = department.name,
|
|
||||||
faculty = department.faculty,
|
|
||||||
lastSyncedAt = now
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val periods = parsed.departments.flatMap { department ->
|
|
||||||
parsed.dateRanges[department.departmentId].orEmpty().flatMap { (season, ranges) ->
|
|
||||||
ranges.map { range ->
|
|
||||||
DepartmentPeriodEntity(
|
|
||||||
departmentId = department.departmentId,
|
|
||||||
season = season,
|
|
||||||
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
|
|
||||||
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
teacherDao.deleteAllPeriods()
|
|
||||||
teacherDao.insertPeriods(periods)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun now(): Long = System.currentTimeMillis()
|
|
||||||
|
|
||||||
private fun DepartmentEntity.toDomain(): Department = Department(
|
|
||||||
departmentId = departmentId,
|
|
||||||
name = name,
|
|
||||||
faculty = faculty
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
package agzam.vyatsu.data.repository
|
|
||||||
|
|
||||||
import agzam.vyatsu.data.local.DepartmentPeriodEntity
|
|
||||||
import agzam.vyatsu.data.local.TeacherDao
|
|
||||||
import agzam.vyatsu.data.local.TeacherDayEntity
|
|
||||||
import agzam.vyatsu.data.local.TeacherLessonEntity
|
|
||||||
import agzam.vyatsu.data.local.TeacherLessonWithDepartment
|
|
||||||
import agzam.vyatsu.data.local.TeacherScheduleDao
|
|
||||||
import agzam.vyatsu.data.local.TeacherScheduleEntity
|
|
||||||
import agzam.vyatsu.data.remote.TeacherScheduleParser
|
|
||||||
import agzam.vyatsu.data.remote.VyatsuHttpClient
|
|
||||||
import agzam.vyatsu.data.remote.VyatsuUrls
|
|
||||||
import agzam.vyatsu.domain.model.BulkSyncProgress
|
|
||||||
import agzam.vyatsu.domain.model.DateRange
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
|
||||||
import agzam.vyatsu.domain.model.Lesson
|
|
||||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
|
||||||
import agzam.vyatsu.domain.model.Teacher
|
|
||||||
import agzam.vyatsu.domain.model.TeacherSchedule
|
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
|
||||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.coroutineScope
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
import java.time.temporal.TemporalAdjusters
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
|
|
||||||
class TeacherScheduleRepositoryImpl(
|
|
||||||
private val scheduleDao: TeacherScheduleDao,
|
|
||||||
private val teacherDao: TeacherDao
|
|
||||||
) : TeacherScheduleRepository {
|
|
||||||
|
|
||||||
private val syncMutex = Mutex()
|
|
||||||
|
|
||||||
override fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> =
|
|
||||||
combine(
|
|
||||||
scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()),
|
|
||||||
scheduleDao.observeLessonsForPeriod(period.season.name, period.weekStart.toEpochDay())
|
|
||||||
) { days, lessons ->
|
|
||||||
buildTeacherSchedule(period, days, lessons)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>> =
|
|
||||||
scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay())
|
|
||||||
.map { names -> names.map { name -> Teacher(name = name) } }
|
|
||||||
|
|
||||||
override suspend fun syncPeriod(date: LocalDate, force: Boolean): Flow<BulkSyncProgress> = flow {
|
|
||||||
// Unique index (departmentId, season, weekStart) breaks on interleaved
|
|
||||||
// delete+insert from concurrent syncPeriod() callers.
|
|
||||||
syncMutex.withLock {
|
|
||||||
val targets = withContext(Dispatchers.IO) {
|
|
||||||
teacherDao.getPeriodsForDate(date.toEpochDay())
|
|
||||||
}
|
|
||||||
if (targets.isEmpty()) {
|
|
||||||
emit(BulkSyncProgress(0, 0, 0))
|
|
||||||
return@withLock
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = now()
|
|
||||||
var skippedCount = 0
|
|
||||||
val nonSkipped = mutableListOf<DepartmentPeriodEntity>()
|
|
||||||
for (target in targets) {
|
|
||||||
val last = withContext(Dispatchers.IO) {
|
|
||||||
scheduleDao.lastSyncedAt(target.departmentId, target.season.name, target.weekStart)
|
|
||||||
}
|
|
||||||
val shouldSkip = !force && last != null && now - last < STALE_WINDOW_MS
|
|
||||||
if (shouldSkip) {
|
|
||||||
skippedCount++
|
|
||||||
} else {
|
|
||||||
nonSkipped.add(target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(BulkSyncProgress(total = targets.size, done = skippedCount, failed = 0))
|
|
||||||
|
|
||||||
val total = targets.size
|
|
||||||
val semaphore = Semaphore(PARALLELISM)
|
|
||||||
val jobs = coroutineScope {
|
|
||||||
nonSkipped.map { target ->
|
|
||||||
async {
|
|
||||||
semaphore.withPermit {
|
|
||||||
try {
|
|
||||||
withContext(Dispatchers.IO) { syncDepartment(target) }
|
|
||||||
true
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"sync: failed for department ${target.departmentId} " +
|
|
||||||
"season ${target.season} week ${target.weekStart} " +
|
|
||||||
"(${e::class.simpleName}: ${e.message})",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var done = skippedCount
|
|
||||||
var failed = 0
|
|
||||||
for (job in jobs) {
|
|
||||||
if (job.await()) done++ else failed++
|
|
||||||
emit(BulkSyncProgress(total, done + failed, failed))
|
|
||||||
}
|
|
||||||
|
|
||||||
withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun hasData(period: SchedulePeriod): Boolean =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay())
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun weeksFor(date: LocalDate): List<SchedulePeriod> =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
teacherDao.getPeriodsForDate(date.toEpochDay())
|
|
||||||
.distinctBy { it.season to (it.weekStart to it.weekEnd) }
|
|
||||||
.flatMap { window ->
|
|
||||||
val windowStart = LocalDate.ofEpochDay(window.weekStart)
|
|
||||||
val windowEnd = LocalDate.ofEpochDay(window.weekEnd)
|
|
||||||
generateSequence(
|
|
||||||
windowStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
|
||||||
) { it.plusWeeks(1) }
|
|
||||||
.takeWhile { it <= windowEnd }
|
|
||||||
.map { monday ->
|
|
||||||
val weekEnd = monday.plusDays(6)
|
|
||||||
SchedulePeriod(
|
|
||||||
weekStart = monday,
|
|
||||||
weekEnd = if (weekEnd.isAfter(windowEnd)) windowEnd else weekEnd,
|
|
||||||
season = window.season
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.distinctBy { it.season to it.weekStart }
|
|
||||||
.sortedBy { it.weekStart }
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun clearAll() = withContext(Dispatchers.IO) {
|
|
||||||
scheduleDao.deleteAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun syncDepartment(target: DepartmentPeriodEntity) {
|
|
||||||
val season = target.season
|
|
||||||
val range = DateRange(
|
|
||||||
LocalDate.ofEpochDay(target.weekStart).format(RANGE_FMT),
|
|
||||||
LocalDate.ofEpochDay(target.weekEnd).format(RANGE_FMT)
|
|
||||||
)
|
|
||||||
val url = VyatsuUrls.teacherPrepodUrl(target.departmentId, season, range)
|
|
||||||
Log.d(TAG, "syncDepartment: dept=${target.departmentId} url=$url")
|
|
||||||
val html = VyatsuHttpClient.getBytes(url).toString(Charsets.UTF_8)
|
|
||||||
val parsed = TeacherScheduleParser.parse(html)
|
|
||||||
|
|
||||||
Log.d(TAG, "syncDepartment: dept=${target.departmentId} parsedDays=${parsed.days.size}")
|
|
||||||
parsed.days.forEach { day ->
|
|
||||||
day.intervals.forEach { slot ->
|
|
||||||
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
|
|
||||||
if (mel.isNotEmpty()) {
|
|
||||||
Log.d(TAG, "syncDepartment: dept=${target.departmentId} date=${day.date} slot=${slot.start}-${slot.end} melLessons=${mel.size}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val dayEntities = parsed.days.map {
|
|
||||||
TeacherDayEntity(scheduleId = 0, date = it.date.toEpochDay())
|
|
||||||
}
|
|
||||||
val lessonEntities = parsed.days.flatMap { day ->
|
|
||||||
day.intervals.flatMapIndexed { slotIndex, slot ->
|
|
||||||
slot.lessons.mapIndexedNotNull { lessonIndex, lesson ->
|
|
||||||
val teacher = lesson.teacher ?: return@mapIndexedNotNull null
|
|
||||||
TeacherLessonEntity(
|
|
||||||
dayId = 0,
|
|
||||||
date = day.date.toEpochDay(),
|
|
||||||
slotIndex = slotIndex,
|
|
||||||
start = slot.start.toMinutesOfDay(),
|
|
||||||
end = slot.end.toMinutesOfDay(),
|
|
||||||
lessonIndex = lessonIndex,
|
|
||||||
raw = lesson.raw,
|
|
||||||
room = lesson.room,
|
|
||||||
groupName = lesson.group,
|
|
||||||
subgroup = lesson.subgroup,
|
|
||||||
subject = lesson.subject,
|
|
||||||
type = lesson.type,
|
|
||||||
teacher = teacher
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Log.d(TAG, "syncDepartment: dept=${target.departmentId} -> lessons=${lessonEntities.size}")
|
|
||||||
val melLessonEntities = lessonEntities.filter { it.teacher == "Мельцов В.Ю." }
|
|
||||||
melLessonEntities.forEach { le ->
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"syncDepartment: dept=${target.departmentId} MEL date=${le.date} slotIndex=${le.slotIndex} " +
|
|
||||||
"start=$le.start end=$le.end raw=${le.raw}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
scheduleDao.replaceSchedule(
|
|
||||||
TeacherScheduleEntity(
|
|
||||||
departmentId = target.departmentId,
|
|
||||||
season = season,
|
|
||||||
weekStart = target.weekStart,
|
|
||||||
weekEnd = target.weekEnd,
|
|
||||||
lastSyncedAt = now()
|
|
||||||
),
|
|
||||||
dayEntities,
|
|
||||||
lessonEntities
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun trimToRecent(keepDistinctPeriods: Int) {
|
|
||||||
val schedules = scheduleDao.getSchedules()
|
|
||||||
schedules
|
|
||||||
.map { it.season to it.weekStart }
|
|
||||||
.distinct()
|
|
||||||
.sortedByDescending { it.second }
|
|
||||||
.drop(keepDistinctPeriods)
|
|
||||||
.forEach { (season, weekStart) ->
|
|
||||||
scheduleDao.deletePeriod(season.name, weekStart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildTeacherSchedule(
|
|
||||||
period: SchedulePeriod,
|
|
||||||
dayEntities: List<TeacherDayEntity>,
|
|
||||||
lessons: List<TeacherLessonWithDepartment>
|
|
||||||
): TeacherSchedule? {
|
|
||||||
val weekDays = dayEntities
|
|
||||||
.filter {
|
|
||||||
it.date >= period.weekStart.toEpochDay() && it.date <= period.weekEnd.toEpochDay()
|
|
||||||
}
|
|
||||||
if (weekDays.isEmpty()) return null
|
|
||||||
val lessonsByDate = lessons.groupBy { it.lesson.date }
|
|
||||||
val days = weekDays
|
|
||||||
.sortedBy { it.date }
|
|
||||||
.distinctBy { it.date }
|
|
||||||
.map { day ->
|
|
||||||
val dayLessons = lessonsByDate[day.date].orEmpty()
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"build: date=${day.date} totalLessons=${dayLessons.size} " +
|
|
||||||
"groupedByStart=${dayLessons.groupBy { it.lesson.start }.keys.sorted()}"
|
|
||||||
)
|
|
||||||
val slots = dayLessons.groupBy { it.lesson.start }
|
|
||||||
.toSortedMap()
|
|
||||||
.map { (startMin, slotLessons) ->
|
|
||||||
val first = slotLessons.first().lesson
|
|
||||||
val times = slotLessons.map { "${it.lesson.start}-${it.lesson.end}" }.distinct()
|
|
||||||
val teachers = slotLessons.map { it.lesson.teacher }.distinct()
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"build: date=${day.date} start=$startMin firstEnd=$first.end " +
|
|
||||||
"times=$times teachers=$teachers"
|
|
||||||
)
|
|
||||||
TimeSlot(
|
|
||||||
start = LocalTime.of(startMin / 60, startMin % 60),
|
|
||||||
end = LocalTime.of(first.end / 60, first.end % 60),
|
|
||||||
lessons = slotLessons
|
|
||||||
.sortedBy { it.lesson.lessonIndex }
|
|
||||||
.map { it.toDomain() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Day(date = LocalDate.ofEpochDay(day.date), intervals = slots)
|
|
||||||
}
|
|
||||||
return TeacherSchedule(period = period, days = days)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun TeacherLessonWithDepartment.toDomain(): Lesson = Lesson(
|
|
||||||
raw = lesson.raw,
|
|
||||||
room = lesson.room,
|
|
||||||
group = lesson.groupName,
|
|
||||||
subgroup = lesson.subgroup,
|
|
||||||
subject = lesson.subject,
|
|
||||||
type = lesson.type,
|
|
||||||
teacher = lesson.teacher,
|
|
||||||
department = departmentLabel(departmentId, departmentName)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Названия, не выводимые акронимом из слов (например «топ-уровня в сфере…»)
|
|
||||||
private val DEPARTMENT_LABELS = mapOf(
|
|
||||||
"974" to "Топ ИИ"
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun departmentLabel(departmentId: String, departmentName: String?): String {
|
|
||||||
DEPARTMENT_LABELS[departmentId]?.let { return it }
|
|
||||||
val name = departmentName ?: return "каф. $departmentId"
|
|
||||||
val words = name.removePrefix("Кафедра ").removePrefix("кафедра ")
|
|
||||||
.substringBefore(" (")
|
|
||||||
.split(" ")
|
|
||||||
.filter { it.isNotBlank() && it != "и" }
|
|
||||||
val acronym = words.joinToString("") { it.first().uppercase().toString() }
|
|
||||||
return acronym.ifBlank { "каф. $departmentId" }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
|
|
||||||
|
|
||||||
private fun now(): Long = System.currentTimeMillis()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
|
|
||||||
private const val PARALLELISM = 2
|
|
||||||
private const val TAG = "TeacherScheduleRepo"
|
|
||||||
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,9 @@ import agzam.vyatsu.data.remote.VyatsuHttpClient
|
|||||||
import agzam.vyatsu.data.repository.GroupRepositoryImpl
|
import agzam.vyatsu.data.repository.GroupRepositoryImpl
|
||||||
import agzam.vyatsu.data.repository.PreferencesRepositoryImpl
|
import agzam.vyatsu.data.repository.PreferencesRepositoryImpl
|
||||||
import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
|
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.GroupRepository
|
||||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
import agzam.vyatsu.domain.repository.PreferencesRepository
|
||||||
import agzam.vyatsu.domain.repository.ScheduleRepository
|
import agzam.vyatsu.domain.repository.ScheduleRepository
|
||||||
import agzam.vyatsu.domain.repository.TeacherRepository
|
|
||||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
|
||||||
import agzam.vyatsu.update.AppUpdater
|
import agzam.vyatsu.update.AppUpdater
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
@@ -23,7 +19,6 @@ class AppContainer(context: Context) {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"vyatsu_schedule.db"
|
"vyatsu_schedule.db"
|
||||||
)
|
)
|
||||||
.addMigrations(AppDatabase.MIGRATION_3_4)
|
|
||||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -44,17 +39,6 @@ class AppContainer(context: Context) {
|
|||||||
PreferencesRepositoryImpl(database.preferencesDao())
|
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 {
|
val appUpdater: AppUpdater by lazy {
|
||||||
AppUpdater(context.applicationContext, preferencesRepository)
|
AppUpdater(context.applicationContext, preferencesRepository)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package agzam.vyatsu.domain.model
|
|
||||||
|
|
||||||
data class BulkSyncProgress(
|
|
||||||
val total: Int,
|
|
||||||
val done: Int,
|
|
||||||
val failed: Int
|
|
||||||
)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package agzam.vyatsu.domain.model
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Кафедра ВятГУ.
|
|
||||||
* departmentId — идентификатор кафедры из data-kaf_period_id без последней цифры (как в URL).
|
|
||||||
*/
|
|
||||||
data class Department(
|
|
||||||
val departmentId: String,
|
|
||||||
val name: String,
|
|
||||||
val faculty: String
|
|
||||||
)
|
|
||||||
@@ -26,6 +26,4 @@ data class Lesson(
|
|||||||
/** Преподаватель **/
|
/** Преподаватель **/
|
||||||
val teacher: String? = null,
|
val teacher: String? = null,
|
||||||
|
|
||||||
/** Кафедра-источник (только в расписании преподавателей) **/
|
|
||||||
val department: String? = null,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
package agzam.vyatsu.domain.model
|
|
||||||
|
|
||||||
data class ParsedDepartments(
|
|
||||||
val departments: List<Department>,
|
|
||||||
val dateRanges: Map<String, Map<Season, List<DateRange>>>
|
|
||||||
)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package agzam.vyatsu.domain.model
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Преподаватель ВятГУ.
|
|
||||||
* Один и тот же преподаватель может вести занятия в нескольких кафедрах,
|
|
||||||
* поэтому модель не привязана к departmentId — расписание агрегируется
|
|
||||||
* по всем кафедрам периода.
|
|
||||||
*/
|
|
||||||
data class Teacher(
|
|
||||||
val name: String
|
|
||||||
)
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package agzam.vyatsu.domain.model
|
|
||||||
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Расписание преподавателя за период, агрегированное по всем кафедрам.
|
|
||||||
*/
|
|
||||||
data class TeacherSchedule(
|
|
||||||
val period: SchedulePeriod,
|
|
||||||
val days: List<Day>
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Возвращает дни периода только с занятиями указанного преподавателя.
|
|
||||||
* Слоты без занятий отбрасываются, порядок [Day] и [TimeSlot] сохраняется.
|
|
||||||
*
|
|
||||||
* Дедупликация — ТОЛЬКО по полному совпадению (дата + слот + аудитория +
|
|
||||||
* группа + предмет + тип + raw): два занятия коллапсируют в одно лишь когда
|
|
||||||
* они идентичны целиком (одно физическое занятие, прочитанное из файлов двух
|
|
||||||
* кафедр). Всё, что отличается хотя бы одним полем, остаётся и показывается.
|
|
||||||
*/
|
|
||||||
fun daysForTeacher(teacherName: String): List<Day> =
|
|
||||||
days.mapNotNull { day ->
|
|
||||||
val seen = HashSet<FullMatchKey>()
|
|
||||||
val intervals = day.intervals.mapNotNull { slot ->
|
|
||||||
val lessons = slot.lessons
|
|
||||||
.filter { it.teacher == teacherName }
|
|
||||||
.filter {
|
|
||||||
seen.add(
|
|
||||||
FullMatchKey(
|
|
||||||
date = day.date,
|
|
||||||
start = slot.start,
|
|
||||||
end = slot.end,
|
|
||||||
room = it.room,
|
|
||||||
group = it.group,
|
|
||||||
subject = it.subject,
|
|
||||||
type = it.type,
|
|
||||||
raw = it.raw
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (lessons.isEmpty()) null else slot.copy(lessons = lessons)
|
|
||||||
}
|
|
||||||
if (intervals.isEmpty()) null else day.copy(intervals = intervals)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class FullMatchKey(
|
|
||||||
val date: LocalDate,
|
|
||||||
val start: LocalTime,
|
|
||||||
val end: LocalTime,
|
|
||||||
val room: String?,
|
|
||||||
val group: String?,
|
|
||||||
val subject: String?,
|
|
||||||
val type: LessonType,
|
|
||||||
val raw: String
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,14 +9,6 @@ interface PreferencesRepository {
|
|||||||
suspend fun selectedDayDate(): String?
|
suspend fun selectedDayDate(): String?
|
||||||
suspend fun setSelectedDayDate(date: 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 autoUpdateEnabled(): Boolean
|
||||||
suspend fun setAutoUpdateEnabled(enabled: Boolean)
|
suspend fun setAutoUpdateEnabled(enabled: Boolean)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
package agzam.vyatsu.domain.repository
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Department
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
interface TeacherRepository {
|
|
||||||
fun observeDepartments(): Flow<List<Department>>
|
|
||||||
suspend fun getDepartments(): List<Department>
|
|
||||||
suspend fun getFaculties(): List<String>
|
|
||||||
suspend fun refreshIfStale(): Boolean
|
|
||||||
suspend fun forceRefresh(): Boolean
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package agzam.vyatsu.domain.repository
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.BulkSyncProgress
|
|
||||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
|
||||||
import agzam.vyatsu.domain.model.Teacher
|
|
||||||
import agzam.vyatsu.domain.model.TeacherSchedule
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
interface TeacherScheduleRepository {
|
|
||||||
fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>>
|
|
||||||
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
|
|
||||||
suspend fun syncPeriod(date: LocalDate, force: Boolean = false): Flow<BulkSyncProgress>
|
|
||||||
suspend fun hasData(period: SchedulePeriod): Boolean
|
|
||||||
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
|
|
||||||
suspend fun clearAll()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package agzam.vyatsu.ui.groups
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Group
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SearchBar
|
||||||
|
import androidx.compose.material3.SearchBarDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница выбора группы
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GroupPickerScreen(viewModel: GroupPickerViewModel, onGroupPicked: (Group) -> Unit) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
var isSearchActive by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Scaffold { innerPadding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.systemBars)
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
if (!isSearchActive) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Выберите группу",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(onClick = { viewModel.refresh() }) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading && state.groups.isEmpty()) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
if (state.error != null && state.groups.isEmpty()) {
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(state.error.orEmpty(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
|
||||||
|
TextButton(onClick = { viewModel.retry() }) { Text("Повторить") }
|
||||||
|
}
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
SearchBar(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
expanded = isSearchActive,
|
||||||
|
onExpandedChange = { isSearchActive = it },
|
||||||
|
inputField = {
|
||||||
|
SearchBarDefaults.InputField(
|
||||||
|
query = state.searchQuery,
|
||||||
|
onQueryChange = { viewModel.search(it) },
|
||||||
|
onSearch = { isSearchActive = false },
|
||||||
|
expanded = isSearchActive,
|
||||||
|
onExpandedChange = { isSearchActive = it },
|
||||||
|
placeholder = { Text("Поиск группы") },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (isSearchActive && state.searchQuery.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.search("") }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = "Очистить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
GroupList(
|
||||||
|
groups = state.filteredGroups,
|
||||||
|
onPick = { group ->
|
||||||
|
isSearchActive = false
|
||||||
|
onGroupPicked(group)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSearchActive) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
GroupList(state.filteredGroups, onGroupPicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(groups) { group ->
|
||||||
|
Column(modifier = Modifier.fillMaxWidth() .clickable { onPick(group) }) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).padding(horizontal = 16.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
|
) {
|
||||||
|
Text(group.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
// XXX: стоит ли добавлять факультет или это засорит интерфейс
|
||||||
|
// Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
|
||||||
|
// if (group.faculty.isNotEmpty()) Text(text = group.faculty + " · ", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package agzam.vyatsu.ui.groups
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Group
|
||||||
|
import agzam.vyatsu.domain.repository.GroupRepository
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Agzam
|
||||||
|
*/
|
||||||
|
class GroupPickerViewModel(private val repository: GroupRepository) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(GroupPickerUiState())
|
||||||
|
val uiState: StateFlow<GroupPickerUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val ok = try {
|
||||||
|
repository.refreshIfStale()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = e.message ?: "Не удалось загрузить группы"
|
||||||
|
)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!ok && _uiState.value.groups.isEmpty()) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = _uiState.value.error ?: "Не удалось обновить список групп"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repository.observeGroups().collect { groups ->
|
||||||
|
val query = _uiState.value.searchQuery
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
groups = groups,
|
||||||
|
filteredGroups = filterGroups(groups, query),
|
||||||
|
error = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(query: String) {
|
||||||
|
val groups = _uiState.value.groups
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
searchQuery = query,
|
||||||
|
filteredGroups = filterGroups(groups, query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
|
||||||
|
if (query.isBlank()) return groups
|
||||||
|
val lowerQuery = query.lowercase()
|
||||||
|
return groups.filter { group ->
|
||||||
|
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retry() {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
repository.forceRefresh()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Не удалось обновить список групп"
|
||||||
|
)
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GroupPickerUiState(
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
val groups: List<Group> = emptyList(),
|
||||||
|
val searchQuery: String = "",
|
||||||
|
val filteredGroups: List<Group> = emptyList()
|
||||||
|
)
|
||||||
@@ -6,8 +6,6 @@ import agzam.vyatsu.ui.schedule.ScheduleScreen
|
|||||||
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
import agzam.vyatsu.ui.schedule.ScheduleViewModel
|
||||||
import agzam.vyatsu.ui.settings.SettingsScreen
|
import agzam.vyatsu.ui.settings.SettingsScreen
|
||||||
import agzam.vyatsu.ui.settings.UpdateViewModel
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -21,7 +19,6 @@ import androidx.compose.material3.NavigationBarItem
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -38,13 +35,10 @@ private enum class HomeTab(val label: String) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(
|
fun HomeScreen(
|
||||||
scheduleViewModel: ScheduleViewModel,
|
scheduleViewModel: ScheduleViewModel,
|
||||||
teacherScheduleViewModel: TeacherScheduleViewModel,
|
|
||||||
updateViewModel: UpdateViewModel,
|
updateViewModel: UpdateViewModel,
|
||||||
onSwitchGroup: () -> Unit,
|
onSwitchGroup: () -> Unit
|
||||||
onSwitchTeacher: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
|
||||||
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
@@ -76,14 +70,7 @@ fun HomeScreen(
|
|||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
) {
|
) {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
HomeTab.SCHEDULE -> if (teacherState.teacherName != null) {
|
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
||||||
TeacherScheduleScreen(
|
|
||||||
viewModel = teacherScheduleViewModel,
|
|
||||||
onSwitchTeacher = onSwitchTeacher
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
|
|
||||||
}
|
|
||||||
HomeTab.CALLS -> CallsScreen()
|
HomeTab.CALLS -> CallsScreen()
|
||||||
HomeTab.BUILDINGS -> BuildingsScreen()
|
HomeTab.BUILDINGS -> BuildingsScreen()
|
||||||
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
|
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
|
||||||
|
|||||||
@@ -54,5 +54,17 @@ fun BuildingsScreen() {
|
|||||||
}
|
}
|
||||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
"Версия ${BuildConfig.VERSION_NAME}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,336 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package agzam.vyatsu.ui.picker
|
|
||||||
|
|
||||||
enum class PickerSegment { GROUPS, TEACHERS }
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package agzam.vyatsu.ui.picker
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.ScheduleDates
|
|
||||||
import agzam.vyatsu.domain.model.BulkSyncProgress
|
|
||||||
import agzam.vyatsu.domain.model.Group
|
|
||||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
|
||||||
import agzam.vyatsu.domain.model.Teacher
|
|
||||||
import agzam.vyatsu.domain.repository.GroupRepository
|
|
||||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
|
||||||
import agzam.vyatsu.domain.repository.TeacherRepository
|
|
||||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.temporal.TemporalAdjusters
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.debounce
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
data class PickerUiState(
|
|
||||||
val segment: PickerSegment = PickerSegment.GROUPS,
|
|
||||||
val groups: List<Group> = emptyList(),
|
|
||||||
val groupQuery: String = "",
|
|
||||||
val filteredGroups: List<Group> = emptyList(),
|
|
||||||
val groupsLoading: Boolean = true,
|
|
||||||
val teachers: List<Teacher> = emptyList(),
|
|
||||||
val teacherQuery: String = "",
|
|
||||||
val filteredTeachers: List<Teacher> = emptyList(),
|
|
||||||
val teachersLoading: Boolean = true,
|
|
||||||
val syncProgress: BulkSyncProgress? = null,
|
|
||||||
val error: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
class PickerViewModel(
|
|
||||||
private val groupRepository: GroupRepository,
|
|
||||||
private val teacherRepository: TeacherRepository,
|
|
||||||
private val teacherScheduleRepository: TeacherScheduleRepository,
|
|
||||||
private val preferencesRepository: PreferencesRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(PickerUiState())
|
|
||||||
val uiState: StateFlow<PickerUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private var teachersJob: kotlinx.coroutines.Job? = null
|
|
||||||
private var syncJob: kotlinx.coroutines.Job? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
groupsLoad()
|
|
||||||
teachersLoad()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectSegment(segment: PickerSegment) {
|
|
||||||
_uiState.value = _uiState.value.copy(segment = segment)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun groupsLoad() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
groupRepository.refreshIfStale()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
error = e.message ?: "Не удалось загрузить группы"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groupRepository.observeGroups().collect { groups ->
|
|
||||||
val query = _uiState.value.groupQuery
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
groupsLoading = false,
|
|
||||||
groups = groups,
|
|
||||||
filteredGroups = filterGroups(groups, query),
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun searchGroup(query: String) {
|
|
||||||
val groups = _uiState.value.groups
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
groupQuery = query,
|
|
||||||
filteredGroups = filterGroups(groups, query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
|
|
||||||
if (query.isBlank()) return groups
|
|
||||||
val lowerQuery = query.lowercase()
|
|
||||||
return groups.filter { group ->
|
|
||||||
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun retryGroups() {
|
|
||||||
_uiState.value = _uiState.value.copy(groupsLoading = true, error = null)
|
|
||||||
groupsLoad()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshGroups() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
groupRepository.forceRefresh()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
error = e.message ?: "Не удалось обновить список групп"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun teachersLoad() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
teacherRepository.refreshIfStale()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
error = e.message ?: "Не удалось загрузить кафедры"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncAndObserveTeachers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun syncAndObserveTeachers() {
|
|
||||||
val today = ScheduleDates.moscowToday()
|
|
||||||
val weekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
|
||||||
val period = SchedulePeriod(
|
|
||||||
weekStart = weekStart,
|
|
||||||
weekEnd = weekStart.plusDays(6),
|
|
||||||
season = ScheduleDates.currentSeason(today)
|
|
||||||
)
|
|
||||||
|
|
||||||
_uiState.value = _uiState.value.copy(syncProgress = null, teachersLoading = true)
|
|
||||||
|
|
||||||
syncJob?.cancel()
|
|
||||||
syncJob = viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
teacherScheduleRepository.syncPeriod(today).collect { progress ->
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
syncProgress = progress,
|
|
||||||
error = if (progress.failed > 0) {
|
|
||||||
"Не удалось загрузить расписание: ${progress.failed} из " +
|
|
||||||
"${progress.total} кафедр. Проверьте подключение к интернету."
|
|
||||||
} else {
|
|
||||||
_uiState.value.error
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
error = e.message ?: "Не удалось загрузить расписание преподавателей"
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
_uiState.value = _uiState.value.copy(teachersLoading = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
teachersJob?.cancel()
|
|
||||||
teachersJob = viewModelScope.launch {
|
|
||||||
teacherScheduleRepository.observeTeachers(period)
|
|
||||||
.debounce(300)
|
|
||||||
.collect { teachers ->
|
|
||||||
val query = _uiState.value.teacherQuery
|
|
||||||
val isLoading = _uiState.value.teachersLoading
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
teachers = teachers,
|
|
||||||
filteredTeachers = filterTeachers(teachers, query),
|
|
||||||
teachersLoading = isLoading && teachers.isEmpty(),
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun searchTeacher(query: String) {
|
|
||||||
val teachers = _uiState.value.teachers
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
teacherQuery = query,
|
|
||||||
filteredTeachers = filterTeachers(teachers, query)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun filterTeachers(teachers: List<Teacher>, query: String): List<Teacher> {
|
|
||||||
if (query.isBlank()) return teachers
|
|
||||||
val lowerQuery = query.lowercase()
|
|
||||||
return teachers.filter { teacher -> teacher.name.lowercase().contains(lowerQuery) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun retryTeachers() {
|
|
||||||
syncAndObserveTeachers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package agzam.vyatsu.ui.schedule
|
package agzam.vyatsu.ui.schedule
|
||||||
|
|
||||||
import agzam.vyatsu.ui.schedule.components.DayChips
|
import agzam.vyatsu.domain.model.Day
|
||||||
import agzam.vyatsu.ui.schedule.components.FULL_DATE
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
import agzam.vyatsu.ui.schedule.components.LessonSlot
|
import agzam.vyatsu.domain.model.SchedulePeriod
|
||||||
import agzam.vyatsu.ui.schedule.components.WeekSelector
|
import agzam.vyatsu.domain.model.TimeSlot
|
||||||
import agzam.vyatsu.ui.schedule.components.dayName
|
import android.widget.Toast
|
||||||
import agzam.vyatsu.ui.schedule.components.hPadding
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -14,31 +16,55 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.only
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.pager.rememberPagerState
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.compositeOver
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
|
||||||
|
val hPadding = 16.dp
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -102,32 +128,6 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
if (day == null) {
|
if (day == null) {
|
||||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
val schedule = state.schedule!!
|
|
||||||
val weekDays = state.weekStart
|
|
||||||
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
|
|
||||||
?.let { schedule.daysOf(it) }
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
|
|
||||||
val pagerState = rememberPagerState(
|
|
||||||
initialPage = selectedDayIndex,
|
|
||||||
pageCount = { weekDays.size }
|
|
||||||
)
|
|
||||||
LaunchedEffect(state.dayDate) {
|
|
||||||
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
|
|
||||||
if (targetPage != -1 && targetPage != pagerState.currentPage) {
|
|
||||||
pagerState.scrollToPage(targetPage) // Мгновенное переключение без анимации
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(pagerState) {
|
|
||||||
snapshotFlow { pagerState.currentPage }.collect { page ->
|
|
||||||
if (pagerState.isScrollInProgress && page in weekDays.indices) {
|
|
||||||
viewModel.selectDay(weekDays[page].date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val isTodayDay = day.date == state.todayDate
|
val isTodayDay = day.date == state.todayDate
|
||||||
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
val dayLabel = if (isTodayDay) "Сегодня " else ""
|
||||||
|
|
||||||
@@ -153,25 +153,15 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
days = weekDays,
|
days = weekDays,
|
||||||
selectedDate = state.dayDate,
|
selectedDate = state.dayDate,
|
||||||
todayDate = state.todayDate,
|
todayDate = state.todayDate,
|
||||||
onSelect = viewModel::selectDay,
|
onSelect = viewModel::selectDay
|
||||||
pagerState = pagerState
|
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
|
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
|
||||||
|
if (day.intervals.all { it.lessons.isEmpty() }) {
|
||||||
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
|
|
||||||
val day = weekDays.getOrNull(page)
|
|
||||||
|
|
||||||
if (day == null || day.intervals.all { it.lessons.isEmpty() }) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
contentPadding = PaddingValues(vertical = 8.dp)
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
) {
|
) {
|
||||||
@@ -185,5 +175,235 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun WeekSelector(
|
||||||
|
periods: List<SchedulePeriod>,
|
||||||
|
selectedWeekStart: LocalDate?,
|
||||||
|
todayWeekStart: LocalDate?,
|
||||||
|
onSelect: (LocalDate) -> Unit
|
||||||
|
) {
|
||||||
|
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.clickable(enabled = true, onClick = { expanded = true })
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
selected?.let(::weekLabel) ?: "Период",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = expanded,
|
||||||
|
onDismissRequest = { expanded = false },
|
||||||
|
modifier = Modifier.widthIn(min = 220.dp)
|
||||||
|
) {
|
||||||
|
periods.forEach { period ->
|
||||||
|
val isTodayWeek = todayWeekStart == period.weekStart
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
weekLabel(period),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = if (isTodayWeek) {
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
"сегодня",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelect(period.weekStart)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayChips(
|
||||||
|
days: List<Day>,
|
||||||
|
selectedDate: LocalDate?,
|
||||||
|
todayDate: LocalDate?,
|
||||||
|
onSelect: (LocalDate) -> Unit
|
||||||
|
) {
|
||||||
|
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp), 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun weekLabel(period: SchedulePeriod): String {
|
||||||
|
val first = period.weekStart
|
||||||
|
return "с ${first.format(CHIP_DATE)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
|
||||||
|
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,
|
||||||
|
lesson.teacher,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
||||||
|
DayOfWeek.MONDAY -> "Пн"
|
||||||
|
DayOfWeek.TUESDAY -> "Вт"
|
||||||
|
DayOfWeek.WEDNESDAY -> "Ср"
|
||||||
|
DayOfWeek.THURSDAY -> "Чт"
|
||||||
|
DayOfWeek.FRIDAY -> "Пт"
|
||||||
|
DayOfWeek.SATURDAY -> "Сб"
|
||||||
|
DayOfWeek.SUNDAY -> "Вс"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
||||||
|
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package agzam.vyatsu.ui.schedule.components
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.LessonType
|
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.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
|
|
||||||
|
|
||||||
if (lesson.department != null) {
|
|
||||||
Text(
|
|
||||||
lesson.department,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = hPadding)
|
|
||||||
)
|
|
||||||
any = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val meta = listOfNotNull(
|
|
||||||
if (lesson.type == LessonType.OTHER) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
lesson.type.label
|
|
||||||
},
|
|
||||||
lesson.subgroup,
|
|
||||||
if (showTeacher) lesson.teacher else lesson.group,
|
|
||||||
lesson.room
|
|
||||||
).filter { it.isNotBlank() }.joinToString(" · ")
|
|
||||||
|
|
||||||
if (meta.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
meta,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = hPadding)
|
|
||||||
)
|
|
||||||
any = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
|
|
||||||
if (subject.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
subject,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
modifier = Modifier.padding(horizontal = hPadding)
|
|
||||||
)
|
|
||||||
any = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!any) {
|
|
||||||
Text(
|
|
||||||
"пусто",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = hPadding)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package agzam.vyatsu.ui.schedule.components
|
|
||||||
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
internal val hPadding = 16.dp
|
|
||||||
|
|
||||||
internal fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
|
|
||||||
DayOfWeek.MONDAY -> "Пн"
|
|
||||||
DayOfWeek.TUESDAY -> "Вт"
|
|
||||||
DayOfWeek.WEDNESDAY -> "Ср"
|
|
||||||
DayOfWeek.THURSDAY -> "Чт"
|
|
||||||
DayOfWeek.FRIDAY -> "Пт"
|
|
||||||
DayOfWeek.SATURDAY -> "Сб"
|
|
||||||
DayOfWeek.SUNDAY -> "Вс"
|
|
||||||
}
|
|
||||||
|
|
||||||
internal val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
|
||||||
internal val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
|
|
||||||
internal val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package agzam.vyatsu.ui.schedule.components
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import java.time.LocalDate
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
internal fun WeekSelector(
|
|
||||||
periods: List<SchedulePeriod>,
|
|
||||||
selectedWeekStart: LocalDate?,
|
|
||||||
todayWeekStart: LocalDate?,
|
|
||||||
onSelect: (LocalDate) -> Unit
|
|
||||||
) {
|
|
||||||
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Box {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.clickable(enabled = true, onClick = { expanded = true })
|
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
selected?.let(::weekLabel) ?: "Период",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = { expanded = false },
|
|
||||||
modifier = Modifier.widthIn(min = 220.dp)
|
|
||||||
) {
|
|
||||||
periods.forEach { period ->
|
|
||||||
val isTodayWeek = todayWeekStart == period.weekStart
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
weekLabel(period),
|
|
||||||
style = MaterialTheme.typography.bodyLarge
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingIcon = if (isTodayWeek) {
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
"сегодня",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
expanded = false
|
|
||||||
onSelect(period.weekStart)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun weekLabel(period: SchedulePeriod): String {
|
|
||||||
val first = period.weekStart
|
|
||||||
return "с ${first.format(CHIP_DATE)}"
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
package agzam.vyatsu.ui.teacher
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.ScheduleDates
|
|
||||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
|
||||||
import agzam.vyatsu.domain.model.TeacherSchedule
|
|
||||||
import agzam.vyatsu.domain.repository.PreferencesRepository
|
|
||||||
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import java.time.DayOfWeek
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.temporal.TemporalAdjusters
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
class TeacherScheduleViewModel(
|
|
||||||
private val scheduleRepository: TeacherScheduleRepository,
|
|
||||||
private val preferencesRepository: PreferencesRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(TeacherScheduleUiState())
|
|
||||||
val uiState: StateFlow<TeacherScheduleUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private var currentTeacherName: String? = null
|
|
||||||
private var currentPeriod: SchedulePeriod? = null
|
|
||||||
private var scheduleJob: Job? = null
|
|
||||||
private var weeksJob: Job? = null
|
|
||||||
private var loadRevision = 0
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val teacherId = preferencesRepository.selectedTeacherId()
|
|
||||||
if (teacherId != null) {
|
|
||||||
selectTeacher(teacherId)
|
|
||||||
} else {
|
|
||||||
_uiState.value = _uiState.value.copy(noTeacher = true, teacherName = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectTeacher(name: String) {
|
|
||||||
val revision = ++loadRevision
|
|
||||||
currentTeacherName = name
|
|
||||||
_uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false)
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
preferencesRepository.setSelectedTeacherId(name)
|
|
||||||
|
|
||||||
val savedWeekStart = runCatching {
|
|
||||||
preferencesRepository.selectedTeacherWeekStart()?.let(LocalDate::parse)
|
|
||||||
}.getOrNull()
|
|
||||||
val savedDayDate = runCatching {
|
|
||||||
preferencesRepository.selectedTeacherDayDate()?.let(LocalDate::parse)
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
val today = ScheduleDates.moscowToday()
|
|
||||||
val weekStart = savedWeekStart
|
|
||||||
?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
|
||||||
if (revision != loadRevision) return@launch
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
noTeacher = false,
|
|
||||||
teacherName = name,
|
|
||||||
weekStart = weekStart,
|
|
||||||
dayDate = savedDayDate
|
|
||||||
)
|
|
||||||
|
|
||||||
val period = SchedulePeriod(
|
|
||||||
weekStart = weekStart,
|
|
||||||
weekEnd = weekStart.plusDays(6),
|
|
||||||
season = ScheduleDates.currentSeason(today)
|
|
||||||
)
|
|
||||||
loadSchedule(period, revision)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadSchedule(period: SchedulePeriod, revision: Int) {
|
|
||||||
if (revision != loadRevision) return
|
|
||||||
scheduleJob?.cancel()
|
|
||||||
scheduleJob = null
|
|
||||||
currentPeriod = period
|
|
||||||
|
|
||||||
if (!scheduleRepository.hasData(period)) {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
|
||||||
try {
|
|
||||||
// force = true: hasData() already proved there is nothing cached for
|
|
||||||
// this period, so never let the staleness window skip the fetch.
|
|
||||||
scheduleRepository.syncPeriod(period.weekStart, force = true).collect { }
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
}
|
|
||||||
if (revision != loadRevision) return
|
|
||||||
if (!scheduleRepository.hasData(period)) {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = false, loadError = true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
weeksJob?.cancel()
|
|
||||||
weeksJob = viewModelScope.launch {
|
|
||||||
val weeks = scheduleRepository.weeksFor(period.weekStart)
|
|
||||||
if (revision != loadRevision) return@launch
|
|
||||||
_uiState.value = _uiState.value.copy(weeks = weeks)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleJob = viewModelScope.launch {
|
|
||||||
scheduleRepository.observeSchedule(period).collect { schedule ->
|
|
||||||
if (revision != loadRevision) {
|
|
||||||
throw CancellationException("stale schedule observation")
|
|
||||||
}
|
|
||||||
currentTeacherName?.let { applySchedule(schedule, it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun applySchedule(schedule: TeacherSchedule?, teacherName: String) {
|
|
||||||
val current = _uiState.value
|
|
||||||
if (schedule == null) return
|
|
||||||
|
|
||||||
val filteredDays = schedule.daysForTeacher(teacherName)
|
|
||||||
android.util.Log.d("TeacherSchedDebug", "apply: teacher=$teacherName periodWeek=$schedule.period rawDays=${schedule.days.size} filteredDays=${filteredDays.size}")
|
|
||||||
filteredDays.forEach { d ->
|
|
||||||
android.util.Log.d(
|
|
||||||
"TeacherSchedDebug",
|
|
||||||
"apply: filteredDay=${d.date} slots=${d.intervals.map { "${it.start}-${it.end}:${it.lessons.size}" }}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val today = ScheduleDates.moscowToday()
|
|
||||||
val todayDate = if (isInPeriod(today, schedule.period)) today else null
|
|
||||||
|
|
||||||
val dayDate = when {
|
|
||||||
filteredDays.any { it.date == current.dayDate } -> current.dayDate
|
|
||||||
filteredDays.any { it.date == todayDate } -> todayDate
|
|
||||||
else -> filteredDays.firstOrNull()?.date
|
|
||||||
}
|
|
||||||
|
|
||||||
preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString())
|
|
||||||
dayDate?.let { preferencesRepository.setSelectedTeacherDayDate(it.toString()) }
|
|
||||||
|
|
||||||
_uiState.value = current.copy(
|
|
||||||
schedule = schedule.copy(days = filteredDays),
|
|
||||||
weekStart = schedule.period.weekStart,
|
|
||||||
dayDate = dayDate,
|
|
||||||
todayDate = todayDate,
|
|
||||||
isLoading = false,
|
|
||||||
loadError = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectWeek(weekStart: LocalDate) {
|
|
||||||
val state = _uiState.value
|
|
||||||
val name = state.teacherName ?: return
|
|
||||||
if (state.weekStart == weekStart) return
|
|
||||||
val revision = ++loadRevision
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString())
|
|
||||||
if (revision != loadRevision) return@launch
|
|
||||||
_uiState.value = _uiState.value.copy(
|
|
||||||
weekStart = weekStart,
|
|
||||||
dayDate = null,
|
|
||||||
schedule = null,
|
|
||||||
isLoading = true,
|
|
||||||
loadError = false
|
|
||||||
)
|
|
||||||
val period = SchedulePeriod(
|
|
||||||
weekStart = weekStart,
|
|
||||||
weekEnd = weekStart.plusDays(6),
|
|
||||||
season = ScheduleDates.currentSeason(weekStart)
|
|
||||||
)
|
|
||||||
loadSchedule(period, revision)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectDay(date: LocalDate) {
|
|
||||||
val state = _uiState.value
|
|
||||||
val schedule = state.schedule ?: return
|
|
||||||
if (date.isBefore(schedule.period.weekStart) || date.isAfter(schedule.period.weekEnd)) return
|
|
||||||
viewModelScope.launch {
|
|
||||||
preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString())
|
|
||||||
preferencesRepository.setSelectedTeacherDayDate(date.toString())
|
|
||||||
_uiState.value = _uiState.value.copy(weekStart = schedule.period.weekStart, dayDate = date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refresh() {
|
|
||||||
val period = currentPeriod ?: return
|
|
||||||
val revision = ++loadRevision
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
|
|
||||||
try {
|
|
||||||
scheduleRepository.syncPeriod(ScheduleDates.moscowToday(), force = true).collect { }
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
}
|
|
||||||
if (revision != loadRevision) return@launch
|
|
||||||
loadSchedule(period, revision)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun switchTeacher() {
|
|
||||||
loadRevision++
|
|
||||||
viewModelScope.launch {
|
|
||||||
currentTeacherName = null
|
|
||||||
currentPeriod = null
|
|
||||||
scheduleJob?.cancel()
|
|
||||||
scheduleJob = null
|
|
||||||
preferencesRepository.clearSelectedTeacher()
|
|
||||||
_uiState.value = TeacherScheduleUiState(noTeacher = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isInPeriod(date: LocalDate, period: SchedulePeriod): Boolean =
|
|
||||||
!date.isBefore(period.weekStart) && !date.isAfter(period.weekEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class TeacherScheduleUiState(
|
|
||||||
val noTeacher: Boolean = true,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val loadError: Boolean = false,
|
|
||||||
val schedule: TeacherSchedule? = null,
|
|
||||||
val teacherName: String? = null,
|
|
||||||
val weekStart: LocalDate? = null,
|
|
||||||
val dayDate: LocalDate? = null,
|
|
||||||
val todayDate: LocalDate? = null,
|
|
||||||
val weeks: List<SchedulePeriod> = emptyList()
|
|
||||||
)
|
|
||||||
@@ -118,7 +118,7 @@ class AppUpdater(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val remoteVersion = release.tagName?.trimStart('v', 'V')
|
val remoteVersion = release.tagName?.trimStart('v', 'V')
|
||||||
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME.trimStart('v', 'V'))) {
|
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME)) {
|
||||||
_state.value = UpdateState.UpToDate
|
_state.value = UpdateState.UpToDate
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUAMEYxCzAJBgNV
|
|
||||||
BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQDExNHbG9iYWxTaWduIFJv
|
|
||||||
b3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAX
|
|
||||||
BgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIi
|
|
||||||
MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08Es
|
|
||||||
CVeJOaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQGvGIFAha/
|
|
||||||
r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud316HCkD7rRlr+/fKYIje
|
|
||||||
2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo0q3v84RLHIf8E6M6cqJaESvWJ3En7YEt
|
|
||||||
bWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSEy132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvj
|
|
||||||
K8Cd+RTyG/FWaha/LIWFzXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD4
|
|
||||||
12lPFzYE+cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCNI/on
|
|
||||||
ccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzsx2sZy/N78CsHpdls
|
|
||||||
eVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqaByFrgY/bxFn63iLABJzjqls2k+g9
|
|
||||||
vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYD
|
|
||||||
VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEM
|
|
||||||
BQADggIBAHx47PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg
|
|
||||||
JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti2kM3S+LGteWy
|
|
||||||
gxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIkpnnpHs6i58FZFZ8d4kuaPp92
|
|
||||||
CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRFFRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZm
|
|
||||||
OUdkLG5NrmJ7v2B0GbhWrJKsFjLtrWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qq
|
|
||||||
JZ4d16GLuc1CLgSkZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwye
|
|
||||||
qiv5u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP4vkYxboz
|
|
||||||
nxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6N3ec592kD3ZDZopD8p/7
|
|
||||||
DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3vouXsXgxT7PntgMTzlSdriVZzH81Xwj3
|
|
||||||
QEUxeCp6
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
Сертификат vyatsu.ru перевыпущен 05.10.2026 и теперь выдан в иерархии
|
|
||||||
GlobalSign Root R46, которая в системный trust store Android попала
|
|
||||||
только в Android 14. На устройствах с Android 13 и ниже все HTTPS-запросы
|
|
||||||
к сайту падают с "Trust anchor for certification path not found".
|
|
||||||
|
|
||||||
Добавляем этот корень как дополнительный якорь доверия ТОЛЬКО для доменов
|
|
||||||
vyatsu.ru — системные корни сохраняются, остальные сайты не затронуты.
|
|
||||||
-->
|
|
||||||
<network-security-config>
|
|
||||||
<base-config>
|
|
||||||
<trust-anchors>
|
|
||||||
<certificates src="system" />
|
|
||||||
</trust-anchors>
|
|
||||||
<cleartextTrafficPermitted>false</cleartextTrafficPermitted>
|
|
||||||
</base-config>
|
|
||||||
<domain-config>
|
|
||||||
<domain includeSubdomains="true">vyatsu.ru</domain>
|
|
||||||
<trust-anchors>
|
|
||||||
<certificates src="system" />
|
|
||||||
<certificates src="@raw/globalsign_root_r46" />
|
|
||||||
</trust-anchors>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package agzam.vyatsu
|
|
||||||
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
|
||||||
*
|
|
||||||
* See [testing documentation](http://d.android.com/tools/testing).
|
|
||||||
*/
|
|
||||||
class ExampleUnitTest {
|
|
||||||
@Test
|
|
||||||
fun addition_isCorrect() {
|
|
||||||
assertEquals(4, 2 + 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package agzam.vyatsu
|
|
||||||
|
|
||||||
import agzam.vyatsu.data.remote.GroupsParser
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class GroupsPipelineMemoryTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun streamingUnderSmallHeap() {
|
|
||||||
val fixture = checkNotNull(javaClass.getResourceAsStream("/groups.html")) {
|
|
||||||
"Fixture groups.html not found in src/test/resources"
|
|
||||||
}
|
|
||||||
val result = fixture.use(GroupsParser::parseStream)
|
|
||||||
assertTrue(result.groups.size > 400)
|
|
||||||
assertTrue(result.groups.any { it.id == "26981" && it.name.contains("ПОДб-1701-08-00") })
|
|
||||||
assertTrue(result.dateRanges.size > 400)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package agzam.vyatsu
|
|
||||||
|
|
||||||
import agzam.vyatsu.domain.model.Day
|
|
||||||
import agzam.vyatsu.domain.model.Lesson
|
|
||||||
import agzam.vyatsu.domain.model.LessonType
|
|
||||||
import agzam.vyatsu.domain.model.SchedulePeriod
|
|
||||||
import agzam.vyatsu.domain.model.Season
|
|
||||||
import agzam.vyatsu.domain.model.TeacherSchedule
|
|
||||||
import agzam.vyatsu.domain.model.TimeSlot
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
|
|
||||||
class TeacherScheduleDomainTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun daysForTeacherFiltersAndPreservesOrder() {
|
|
||||||
val date1 = LocalDate.of(2026, 9, 14)
|
|
||||||
val date2 = LocalDate.of(2026, 9, 15)
|
|
||||||
val slot1 = TimeSlot(
|
|
||||||
LocalTime.of(8, 20),
|
|
||||||
LocalTime.of(9, 50),
|
|
||||||
listOf(lesson(room = "4-302", teacher = "A"), lesson(room = "5-100", teacher = "B"))
|
|
||||||
)
|
|
||||||
val slot2 = TimeSlot(
|
|
||||||
LocalTime.of(10, 0),
|
|
||||||
LocalTime.of(11, 30),
|
|
||||||
listOf(lesson(room = "5-100", teacher = "B"))
|
|
||||||
)
|
|
||||||
val day2Slot = TimeSlot(
|
|
||||||
LocalTime.of(8, 20),
|
|
||||||
LocalTime.of(9, 50),
|
|
||||||
listOf(lesson(room = "5-100", teacher = "B"))
|
|
||||||
)
|
|
||||||
val schedule = TeacherSchedule(
|
|
||||||
period = SchedulePeriod(date1, date2, Season.AUTUMN),
|
|
||||||
days = listOf(Day(date1, listOf(slot1, slot2)), Day(date2, listOf(day2Slot)))
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = schedule.daysForTeacher("A")
|
|
||||||
|
|
||||||
assertEquals(1, result.size)
|
|
||||||
assertEquals(date1, result[0].date)
|
|
||||||
assertEquals(1, result[0].intervals.size)
|
|
||||||
assertEquals(LocalTime.of(8, 20), result[0].intervals[0].start)
|
|
||||||
assertEquals(1, result[0].intervals[0].lessons.size)
|
|
||||||
assertEquals("A", result[0].intervals[0].lessons[0].teacher)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun daysForTeacherDropsDayWithoutTeacher() {
|
|
||||||
val date1 = LocalDate.of(2026, 9, 14)
|
|
||||||
val date2 = LocalDate.of(2026, 9, 15)
|
|
||||||
val schedule = TeacherSchedule(
|
|
||||||
period = SchedulePeriod(date1, date2, Season.AUTUMN),
|
|
||||||
days = listOf(
|
|
||||||
Day(date1, listOf(slotOf(lesson(room = "4-302", teacher = "A")))),
|
|
||||||
Day(date2, listOf(slotOf(lesson(room = "5-100", teacher = "B"))))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = schedule.daysForTeacher("A")
|
|
||||||
|
|
||||||
assertEquals(1, result.size)
|
|
||||||
assertEquals(date1, result[0].date)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun dedupCollapsesOnlyIdenticalLessons() {
|
|
||||||
val date = LocalDate.of(2026, 9, 14)
|
|
||||||
val raw = "4-302 Программирование Лекция ИВТб-3304-01-00"
|
|
||||||
val identical = lesson(
|
|
||||||
room = "4-302",
|
|
||||||
group = "ИВТб-3304-01-00",
|
|
||||||
teacher = "A",
|
|
||||||
raw = raw,
|
|
||||||
subject = "Программирование"
|
|
||||||
)
|
|
||||||
val differsInGroup = identical.copy(group = "ИВТб-3304-02-00")
|
|
||||||
val differsInRoom = identical.copy(room = "5-102")
|
|
||||||
|
|
||||||
val schedule = TeacherSchedule(
|
|
||||||
period = SchedulePeriod(date, date, Season.AUTUMN),
|
|
||||||
days = listOf(
|
|
||||||
Day(
|
|
||||||
date,
|
|
||||||
listOf(
|
|
||||||
TimeSlot(
|
|
||||||
LocalTime.of(8, 20),
|
|
||||||
LocalTime.of(9, 50),
|
|
||||||
listOf(identical, identical, differsInGroup, differsInRoom)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = schedule.daysForTeacher("A")
|
|
||||||
|
|
||||||
val lessons = result[0].intervals[0].lessons
|
|
||||||
assertEquals(3, lessons.size)
|
|
||||||
assertEquals(1, lessons.count { it == identical })
|
|
||||||
assertEquals(1, lessons.count { it.room == "4-302" && it.group == "ИВТб-3304-01-00" })
|
|
||||||
assertTrue(lessons.any { it.group == "ИВТб-3304-02-00" })
|
|
||||||
assertTrue(lessons.any { it.room == "5-102" })
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun slotOf(lesson: Lesson) =
|
|
||||||
TimeSlot(LocalTime.of(8, 20), LocalTime.of(9, 50), listOf(lesson))
|
|
||||||
|
|
||||||
private fun lesson(
|
|
||||||
room: String,
|
|
||||||
group: String = "ИВТб-3304-01-00",
|
|
||||||
teacher: String,
|
|
||||||
raw: String = "$room Программирование Лекция $group",
|
|
||||||
subject: String = "Программирование"
|
|
||||||
) = Lesson(
|
|
||||||
raw = raw,
|
|
||||||
room = room,
|
|
||||||
group = group,
|
|
||||||
subject = subject,
|
|
||||||
type = LessonType.LECTURE,
|
|
||||||
teacher = teacher
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
package agzam.vyatsu
|
|
||||||
|
|
||||||
import agzam.vyatsu.data.remote.TeacherScheduleParser
|
|
||||||
import agzam.vyatsu.domain.model.LessonType
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.LocalTime
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class TeacherScheduleParserTest {
|
|
||||||
|
|
||||||
private fun fixture(): String =
|
|
||||||
checkNotNull(javaClass.getResourceAsStream("/prepod.html"))
|
|
||||||
.readBytes().toString(Charsets.UTF_8)
|
|
||||||
|
|
||||||
private fun fixture517(): String =
|
|
||||||
checkNotNull(javaClass.getResourceAsStream("/prepod517.html"))
|
|
||||||
.readBytes().toString(Charsets.UTF_8)
|
|
||||||
|
|
||||||
private fun fixture974(): String =
|
|
||||||
checkNotNull(javaClass.getResourceAsStream("/prepod974.html"))
|
|
||||||
.readBytes().toString(Charsets.UTF_8)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun teachersHeaderParsedInOrderAndDeduped() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture())
|
|
||||||
assertEquals(listOf("Лелекова Е.В.", "Иванов И.И.", "Петрова А.С."), parsed.teachers)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun daysWithCorrectDates() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture())
|
|
||||||
assertEquals(2, parsed.days.size)
|
|
||||||
assertEquals(LocalDate.of(2026, 9, 14), parsed.days[0].date)
|
|
||||||
assertEquals(LocalDate.of(2026, 9, 15), parsed.days[1].date)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun intervalTimesParsed() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture())
|
|
||||||
val day0 = parsed.days[0]
|
|
||||||
assertEquals(2, day0.intervals.size)
|
|
||||||
assertEquals(LocalTime.of(8, 20), day0.intervals[0].start)
|
|
||||||
assertEquals(LocalTime.of(9, 50), day0.intervals[0].end)
|
|
||||||
assertEquals(LocalTime.of(10, 0), day0.intervals[1].start)
|
|
||||||
assertEquals(LocalTime.of(11, 30), day0.intervals[1].end)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun multiGroupCellSplitsIntoPerGroupLessons() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture())
|
|
||||||
val cell = parsed.days[0].intervals[0].lessons.filter { it.teacher == "Лелекова Е.В." }
|
|
||||||
assertEquals(2, cell.size)
|
|
||||||
val lesson0 = cell[0]
|
|
||||||
val lesson1 = cell[1]
|
|
||||||
assertEquals("Бб-1101-04-00", lesson0.group)
|
|
||||||
assertNull(lesson0.subgroup)
|
|
||||||
assertEquals("01 подгруппа", lesson1.subgroup)
|
|
||||||
assertEquals("4-302", lesson0.room)
|
|
||||||
assertEquals("Биоразнообразие", lesson0.subject)
|
|
||||||
assertEquals(LessonType.LECTURE, lesson0.type)
|
|
||||||
assertEquals("Лелекова Е.В.", lesson0.teacher)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun emptyCellContributesNoLesson() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture())
|
|
||||||
val threeSlots = (0..6).map { slot ->
|
|
||||||
parsed.days[0].intervals.firstOrNull { it.start.hour == 8 && it.start.minute == 20 } ?: return@map true
|
|
||||||
}
|
|
||||||
val petrovLessons = parsed.days[0].intervals[0].lessons.filter { it.teacher == "Петрова А.С." }
|
|
||||||
assertEquals(0, petrovLessons.size)
|
|
||||||
assertTrue(threeSlots.isEmpty() || threeSlots.size == 7)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun noRoomCellKeepsRawAndNullRoom() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture())
|
|
||||||
val cell = parsed.days[0].intervals[1].lessons.filter { it.teacher == "Лелекова Е.В." }
|
|
||||||
assertEquals(2, cell.size)
|
|
||||||
assertNull(cell[0].room)
|
|
||||||
assertTrue(cell[0].raw.contains("Бб-1101-04-00"))
|
|
||||||
assertNull(cell[0].subject)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun exactTotalLessonCount() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture())
|
|
||||||
val total = parsed.days.sumOf { day -> day.intervals.sumOf { it.lessons.size } }
|
|
||||||
assertEquals(11, total)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun spacerColumnFixture(): String = """
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>День</td><td>Интервал</td><td></td>
|
|
||||||
<td><span>Марков Р.В.</span></td><td><span>Мельцов В.Ю.</span></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>четверг 08.10.26</td><td>08:20-09:50</td><td></td>
|
|
||||||
<td>2-206 Математика Практическое занятие УТб-2301-02-00</td>
|
|
||||||
<td>1-334 Организация параллельных вычислительных систем Лекция ИВТб-4301-05-00</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>10:00-11:30</td><td></td>
|
|
||||||
<td>2-206 Математика Практическое занятие УТб-2301-02-00</td>
|
|
||||||
<td>1-334 Организация параллельных вычислительных систем Лекция ИВТб-4301-05-00</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun emptyHeaderSpacerDoesNotShiftTeacherColumns() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(spacerColumnFixture())
|
|
||||||
assertEquals(listOf("Марков Р.В.", "Мельцов В.Ю."), parsed.teachers)
|
|
||||||
assertEquals(1, parsed.days.size)
|
|
||||||
assertEquals(LocalDate.of(2026, 10, 8), parsed.days[0].date)
|
|
||||||
assertEquals(2, parsed.days[0].intervals.size)
|
|
||||||
for (slot in parsed.days[0].intervals) {
|
|
||||||
val markov = slot.lessons.filter { it.teacher == "Марков Р.В." }
|
|
||||||
val meltsov = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
|
|
||||||
assertEquals(1, markov.size)
|
|
||||||
assertEquals(1, meltsov.size)
|
|
||||||
assertEquals("Математика", markov[0].subject)
|
|
||||||
assertEquals("Организация параллельных вычислительных систем", meltsov[0].subject)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun realPage517TeacherColumnsAligned() {
|
|
||||||
val html = checkNotNull(javaClass.getResourceAsStream("/prepod517.html"))
|
|
||||||
.readBytes().toString(Charsets.UTF_8)
|
|
||||||
val parsed = TeacherScheduleParser.parse(html)
|
|
||||||
assertTrue(parsed.teachers.contains("Мельцов В.Ю."))
|
|
||||||
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 8) }
|
|
||||||
val all = day.intervals.flatMap { it.lessons }
|
|
||||||
val markov = all.filter { it.teacher == "Марков Р.В." }
|
|
||||||
val meltsov = all.filter { it.teacher == "Мельцов В.Ю." }
|
|
||||||
assertTrue("Мельцов должен иметь пары 08.10", meltsov.isNotEmpty())
|
|
||||||
assertTrue(
|
|
||||||
"У Мельцова не должно быть математики Маркова",
|
|
||||||
meltsov.none { it.subject?.contains("Математика") == true }
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"У Маркова должна остаться математика",
|
|
||||||
markov.any { it.subject?.contains("Математика") == true }
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"У Мельцова параллельные вычислительные системы",
|
|
||||||
meltsov.any { it.subject?.contains("Организация параллельных") == true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun realPage517MeltsovLabsSitAt1000And1145NotAt0820() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture517())
|
|
||||||
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 6) }
|
|
||||||
val labsAt = { time: java.time.LocalTime ->
|
|
||||||
day.intervals.firstOrNull { it.start == time }
|
|
||||||
?.lessons
|
|
||||||
?.filter { it.teacher == "Мельцов В.Ю." && it.type == LessonType.LABORATORY }
|
|
||||||
.orEmpty()
|
|
||||||
}
|
|
||||||
assertEquals(
|
|
||||||
"В 08:20 у Мельцова на странице 517 лабораторных нет",
|
|
||||||
0, labsAt(java.time.LocalTime.of(8, 20)).size
|
|
||||||
)
|
|
||||||
assertTrue(labsAt(java.time.LocalTime.of(10, 0)).isNotEmpty())
|
|
||||||
assertTrue(labsAt(java.time.LocalTime.of(11, 45)).isNotEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun realPage974MeltsovHasPracticeAt0820() {
|
|
||||||
val parsed = TeacherScheduleParser.parse(fixture974())
|
|
||||||
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 6) }
|
|
||||||
val slot = day.intervals.first { it.start == java.time.LocalTime.of(8, 20) }
|
|
||||||
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
|
|
||||||
assertTrue(mel.isNotEmpty())
|
|
||||||
assertTrue("08:20 на странице 974 — практическое занятие", mel.all { it.type == LessonType.PRACTICE })
|
|
||||||
assertEquals("ИВТб-2305-06-00", mel.first().group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package agzam.vyatsu
|
|
||||||
|
|
||||||
import agzam.vyatsu.data.remote.TeachersParser
|
|
||||||
import agzam.vyatsu.domain.model.DateRange
|
|
||||||
import agzam.vyatsu.domain.model.Season
|
|
||||||
import java.io.InputStream
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class TeachersPipelineMemoryTest {
|
|
||||||
|
|
||||||
private fun fixture(): InputStream =
|
|
||||||
checkNotNull(javaClass.getResourceAsStream("/teacher.html")) {
|
|
||||||
"Fixture teacher.html not found in src/test/resources"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun parsesDepartmentsAndFaculties() {
|
|
||||||
val result = fixture().use(TeachersParser::parseStream)
|
|
||||||
assertTrue("expected >=4 departments", result.departments.size >= 4)
|
|
||||||
|
|
||||||
val ids = result.departments.map { it.departmentId }.toSet()
|
|
||||||
assertEquals(setOf("11111", "22222", "33333", "44444"), ids)
|
|
||||||
|
|
||||||
val names = result.departments.map { it.name }
|
|
||||||
assertTrue(
|
|
||||||
"nested tags must be stripped from kafPeriod names",
|
|
||||||
names.contains("Кафедра технологий и конструирования машин и аппаратов")
|
|
||||||
)
|
|
||||||
|
|
||||||
val avt = result.departments.first { it.departmentId == "11111" }
|
|
||||||
assertTrue("faculty must be mapped via shortname", avt.faculty.contains("ФАВТ"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun chunkBoundarySafe() {
|
|
||||||
val full = fixture().use(TeachersParser::parseStream)
|
|
||||||
val limited = limitReads(fixture(), 64).use(TeachersParser::parseStream)
|
|
||||||
assertEquals(full, limited)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun seasonAndRanges() {
|
|
||||||
val result = fixture().use(TeachersParser::parseStream)
|
|
||||||
|
|
||||||
val hasAutumn = result.dateRanges.values.any { it[Season.AUTUMN]?.isNotEmpty() == true }
|
|
||||||
val hasSpring = result.dateRanges.values.any { it[Season.SPRING]?.isNotEmpty() == true }
|
|
||||||
assertTrue("AUTUMN season must be present", hasAutumn)
|
|
||||||
assertTrue("SPRING season must be present", hasSpring)
|
|
||||||
|
|
||||||
val autumn = result.dateRanges["11111"]?.get(Season.AUTUMN).orEmpty()
|
|
||||||
assertTrue(
|
|
||||||
"DateRange must be parsed from URL groups",
|
|
||||||
autumn.contains(DateRange("01092026", "13092026"))
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
"second AUTUMN range must be present",
|
|
||||||
autumn.contains(DateRange("14092026", "27092026"))
|
|
||||||
)
|
|
||||||
|
|
||||||
val spring = result.dateRanges["44444"]?.get(Season.SPRING).orEmpty()
|
|
||||||
assertTrue(
|
|
||||||
"SPRING DateRange must be parsed from URL groups",
|
|
||||||
spring.contains(DateRange("02022026", "27022026"))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun limitReads(input: InputStream, maxBytes: Int): InputStream =
|
|
||||||
object : InputStream() {
|
|
||||||
override fun read(): Int = input.read()
|
|
||||||
|
|
||||||
override fun read(b: ByteArray, off: Int, len: Int): Int =
|
|
||||||
input.read(b, off, minOf(len, maxBytes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package agzam.vyatsu.data.local
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.LessonType
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalTime
|
||||||
|
|
||||||
|
class ConvertersTest {
|
||||||
|
|
||||||
|
private val converters = Converters()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localDate_roundTripsThroughEpochDay() {
|
||||||
|
val date = LocalDate.of(2026, 9, 14)
|
||||||
|
assertEquals(date, converters.epochDayToLocalDate(converters.localDateToEpochDay(date)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nullDate_staysNull() {
|
||||||
|
assertNull(converters.localDateToEpochDay(null))
|
||||||
|
assertNull(converters.epochDayToLocalDate(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localTime_roundTripsThroughMinutes() {
|
||||||
|
assertEquals(
|
||||||
|
LocalTime.of(8, 20),
|
||||||
|
converters.minutesToLocalTime(converters.localTimeToMinutes(LocalTime.of(8, 20)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localTime_boundariesMapToMinutes() {
|
||||||
|
assertEquals(0, converters.localTimeToMinutes(LocalTime.of(0, 0)))
|
||||||
|
assertEquals(1439, converters.localTimeToMinutes(LocalTime.of(23, 59)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nullTime_staysNull() {
|
||||||
|
assertNull(converters.localTimeToMinutes(null))
|
||||||
|
assertNull(converters.minutesToLocalTime(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lessonType_roundTripsEveryValue() {
|
||||||
|
for (type in LessonType.entries) {
|
||||||
|
assertEquals(type, converters.stringToLessonType(converters.lessonTypeToString(type)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unknownLessonType_isOther() {
|
||||||
|
assertEquals(LessonType.OTHER, converters.stringToLessonType("NOPE"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package agzam.vyatsu.data.remote
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class GroupsParserTest {
|
||||||
|
|
||||||
|
private val faculty = "Факультет автоматики и вычислительной техники (ОРУ)"
|
||||||
|
private val facultyShort = "ФАВТ (Факультет автоматики и вычислительной техники)"
|
||||||
|
|
||||||
|
private fun page(vararg blocks: String): String = blocks.joinToString("\n")
|
||||||
|
|
||||||
|
private fun facultyDiv(name: String): String = """<div class="fak_name">$name</div>"""
|
||||||
|
|
||||||
|
private fun groupDiv(periodId: String, name: String): String =
|
||||||
|
"""<div class="grpPeriod" data-grp_period_id="$periodId">$name</div>"""
|
||||||
|
|
||||||
|
private fun pdfLink(url: String): String = """<a href="$url">x</a>"""
|
||||||
|
|
||||||
|
private fun parse(html: String): ParsedGroups =
|
||||||
|
GroupsParser.parseStream(html.byteInputStream(Charsets.UTF_8))
|
||||||
|
|
||||||
|
private fun parseLineByLine(html: String): ParsedGroups {
|
||||||
|
val lines = html.split("\n").map { "$it\n" }
|
||||||
|
var index = 0
|
||||||
|
val stream = object : InputStream() {
|
||||||
|
override fun read(): Int = -1
|
||||||
|
override fun read(b: ByteArray, off: Int, len: Int): Int {
|
||||||
|
if (index >= lines.size) return -1
|
||||||
|
val bytes = lines[index].toByteArray(Charsets.UTF_8)
|
||||||
|
val n = minOf(len, bytes.size)
|
||||||
|
System.arraycopy(bytes, 0, b, off, n)
|
||||||
|
index++
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GroupsParser.parseStream(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dropsLastDigitFromPeriodId() {
|
||||||
|
val html = page(
|
||||||
|
facultyDiv(faculty),
|
||||||
|
groupDiv("123456", "ИВТб-3304-05-00 (бакалавриат)")
|
||||||
|
)
|
||||||
|
|
||||||
|
val group = parse(html).groups.single()
|
||||||
|
|
||||||
|
assertEquals("12345", group.id)
|
||||||
|
assertEquals("ИВТб-3304-05-00", group.name)
|
||||||
|
assertEquals(facultyShort, group.faculty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removesParenthesizedSuffixFromGroupName() {
|
||||||
|
val html = page(facultyDiv(faculty), groupDiv("777", "Группа А (подгруппа)"))
|
||||||
|
|
||||||
|
val group = parse(html).groups.single()
|
||||||
|
|
||||||
|
assertEquals("77", group.id)
|
||||||
|
assertEquals("Группа А", group.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keepsFacultyNameWhenNoShorthand() {
|
||||||
|
val html = page(facultyDiv("Некий институт (ОРУ)"), groupDiv("5555", "Гр"))
|
||||||
|
|
||||||
|
val group = parse(html).groups.single()
|
||||||
|
|
||||||
|
assertEquals("Некий институт", group.faculty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun facultyAppliesToGroupsUntilChanged() {
|
||||||
|
val html = page(
|
||||||
|
facultyDiv(faculty),
|
||||||
|
groupDiv("1111", "А-1"),
|
||||||
|
groupDiv("2222", "А-2"),
|
||||||
|
facultyDiv("Юридический институт (факультет) (ОРУ)"),
|
||||||
|
groupDiv("3333", "Ю-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
val faculties = parse(html).groups.map { it.faculty }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(facultyShort, facultyShort, "ЮИ (Юридический институт)"),
|
||||||
|
faculties
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsesDateRangesGroupedBySeason() {
|
||||||
|
val html = page(
|
||||||
|
pdfLink("/reports/schedule/Group/12345_1_14092026_27092026.pdf"),
|
||||||
|
pdfLink("/reports/schedule/Group/12345_1_28092026_11102026.pdf"),
|
||||||
|
pdfLink("/reports/schedule/Group/12345_2_09022027_22022027.pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
val ranges = parse(html).dateRanges
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(DateRange("14092026", "27092026"), DateRange("28092026", "11102026")),
|
||||||
|
ranges["12345"]?.get(Season.AUTUMN)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(DateRange("09022027", "22022027")),
|
||||||
|
ranges["12345"]?.get(Season.SPRING)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ignoresUnrelatedLinks() {
|
||||||
|
val html = page(
|
||||||
|
pdfLink("/other/12345_1_14092026_27092026.pdf"),
|
||||||
|
pdfLink("/reports/schedule/Group/123_1_14092026_27092026.pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(parse(html).dateRanges.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun extractsRangesFromTextBeforeTag() {
|
||||||
|
val html = "/reports/schedule/Group/12345_1_14092026_27092026.pdf " + pdfLink("#")
|
||||||
|
|
||||||
|
val ranges = parse(html).dateRanges
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(DateRange("14092026", "27092026")),
|
||||||
|
ranges["12345"]?.get(Season.AUTUMN)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun greaterThanInsideQuotedAttributeDoesNotBreakTag() {
|
||||||
|
val html = page(
|
||||||
|
facultyDiv(faculty),
|
||||||
|
"""<div class="grpPeriod" data-grp_period_id="555" title="a>b">Группа Б</div>"""
|
||||||
|
)
|
||||||
|
|
||||||
|
val group = parse(html).groups.single()
|
||||||
|
|
||||||
|
assertEquals("55", group.id)
|
||||||
|
assertEquals("Группа Б", group.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleReadsProduceSameResultAsSingleRead() {
|
||||||
|
val html = page(
|
||||||
|
facultyDiv(faculty),
|
||||||
|
groupDiv("123456", "ИВТб-3304-05-00 (бакалавриат)"),
|
||||||
|
pdfLink("/reports/schedule/Group/12345_1_14092026_27092026.pdf"),
|
||||||
|
groupDiv("7654321", "ИВТб-3304-05-01"),
|
||||||
|
pdfLink("/reports/schedule/Group/765432_2_09022027_22022027.pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(parse(html), parseLineByLine(html))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package agzam.vyatsu.data.remote
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.DateRange
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VyatsuUrlsTest {
|
||||||
|
|
||||||
|
private val autumnRange = DateRange("14092026", "27092026")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupPdfUrl_usesAutumnKey() {
|
||||||
|
assertEquals(
|
||||||
|
"https://www.vyatsu.ru/reports/schedule/Group/12345_1_14092026_27092026.pdf",
|
||||||
|
VyatsuUrls.groupPdfUrl("12345", Season.AUTUMN, autumnRange)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun groupPdfUrl_usesSpringKey() {
|
||||||
|
assertEquals(
|
||||||
|
"https://www.vyatsu.ru/reports/schedule/Group/765432_2_09022027_22022027.pdf",
|
||||||
|
VyatsuUrls.groupPdfUrl("765432", Season.SPRING, DateRange("09022027", "22022027"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun season_apiKeysMatchUrlScheme() {
|
||||||
|
assertEquals("1", Season.AUTUMN.apiKey)
|
||||||
|
assertEquals("2", Season.SPRING.apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun generatedUrl_isRecognizedByGroupsParser() {
|
||||||
|
val url = VyatsuUrls.groupPdfUrl("12345", Season.AUTUMN, autumnRange)
|
||||||
|
val html = """<a href="$url">x</a>"""
|
||||||
|
|
||||||
|
val parsed = GroupsParser.parseStream(html.byteInputStream(Charsets.UTF_8))
|
||||||
|
|
||||||
|
assertEquals(listOf(autumnRange), parsed.dateRanges["12345"]?.get(Season.AUTUMN))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package agzam.vyatsu.domain
|
||||||
|
|
||||||
|
import agzam.vyatsu.domain.model.Season
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
|
||||||
|
class ScheduleDatesTest {
|
||||||
|
|
||||||
|
private val firstMonday = "14092026"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun asDate_parsesDdMmYyyy() {
|
||||||
|
assertEquals(LocalDate.of(2026, 9, 14), ScheduleDates.asDate("14092026"))
|
||||||
|
assertEquals(LocalDate.of(2026, 9, 1), ScheduleDates.asDate("01092026"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun asDate_wrongFormat_throws() {
|
||||||
|
assertThrows(DateTimeParseException::class.java) {
|
||||||
|
ScheduleDates.asDate("2026-09-14")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayIndexes_mondayOfFirstWeek_isZeroZero() {
|
||||||
|
assertEquals(0 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 14)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayIndexes_tuesdayToSaturday_areSameWeek() {
|
||||||
|
assertEquals(0 to 1, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 15)))
|
||||||
|
assertEquals(0 to 2, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 16)))
|
||||||
|
assertEquals(0 to 3, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 17)))
|
||||||
|
assertEquals(0 to 4, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 18)))
|
||||||
|
assertEquals(0 to 5, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 19)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayIndexes_sunday_movesToNextMonday() {
|
||||||
|
assertEquals(1 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 20)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayIndexes_secondWeek() {
|
||||||
|
assertEquals(1 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 21)))
|
||||||
|
assertEquals(1 to 1, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 22)))
|
||||||
|
assertEquals(1 to 5, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 26)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayIndexes_thirdCalendarWeek_wrapsToFirstScheduleWeek() {
|
||||||
|
assertEquals(0 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 28)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dayIndexes_dateBeforeFirstMonday_isSecondScheduleWeek() {
|
||||||
|
assertEquals(1 to 0, ScheduleDates.dayIndexes(firstMonday, LocalDate.of(2026, 9, 7)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dateByIndexes_composesFromFirstDate() {
|
||||||
|
assertEquals(LocalDate.of(2026, 9, 14), ScheduleDates.dateByIndexes(firstMonday, 0, 0))
|
||||||
|
assertEquals(LocalDate.of(2026, 9, 15), ScheduleDates.dateByIndexes(firstMonday, 0, 1))
|
||||||
|
assertEquals(LocalDate.of(2026, 9, 21), ScheduleDates.dateByIndexes(firstMonday, 1, 0))
|
||||||
|
assertEquals(LocalDate.of(2026, 9, 27), ScheduleDates.dateByIndexes(firstMonday, 1, 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun currentSeason_augustToDecember_isAutumn() {
|
||||||
|
assertEquals(Season.AUTUMN, ScheduleDates.currentSeason(LocalDate.of(2026, 8, 1)))
|
||||||
|
assertEquals(Season.AUTUMN, ScheduleDates.currentSeason(LocalDate.of(2026, 12, 31)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun currentSeason_januaryToJuly_isSpring() {
|
||||||
|
assertEquals(Season.SPRING, ScheduleDates.currentSeason(LocalDate.of(2026, 1, 1)))
|
||||||
|
assertEquals(Season.SPRING, ScheduleDates.currentSeason(LocalDate.of(2026, 7, 31)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LessonTypeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fromRaw_recognizesExactLabels() {
|
||||||
|
assertEquals(LessonType.LECTURE, LessonType.fromRaw("Лекция"))
|
||||||
|
assertEquals(LessonType.PRACTICE, LessonType.fromRaw("Практическое занятие"))
|
||||||
|
assertEquals(LessonType.LABORATORY, LessonType.fromRaw("Лабораторная работа"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fromRaw_trimsSurroundingWhitespace() {
|
||||||
|
assertEquals(LessonType.LECTURE, LessonType.fromRaw(" Лекция "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fromRaw_unknownValue_isOther() {
|
||||||
|
assertEquals(LessonType.OTHER, LessonType.fromRaw("Семинар"))
|
||||||
|
assertEquals(LessonType.OTHER, LessonType.fromRaw(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun labels_matchParserVocabulary() {
|
||||||
|
assertEquals("Лекция", LessonType.LECTURE.label)
|
||||||
|
assertEquals("Практическое занятие", LessonType.PRACTICE.label)
|
||||||
|
assertEquals("Лабораторная работа", LessonType.LABORATORY.label)
|
||||||
|
assertEquals("Занятие", LessonType.OTHER.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fromRaw_roundTripsEveryLabel() {
|
||||||
|
for (type in LessonType.entries) {
|
||||||
|
assertEquals(type, LessonType.fromRaw(type.label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package agzam.vyatsu.domain.model
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
class ScheduleTest {
|
||||||
|
|
||||||
|
private val autumnPeriod = SchedulePeriod(
|
||||||
|
weekStart = LocalDate.of(2026, 9, 14),
|
||||||
|
weekEnd = LocalDate.of(2026, 9, 27),
|
||||||
|
season = Season.AUTUMN
|
||||||
|
)
|
||||||
|
private val nextPeriod = SchedulePeriod(
|
||||||
|
weekStart = LocalDate.of(2026, 9, 28),
|
||||||
|
weekEnd = LocalDate.of(2026, 10, 11),
|
||||||
|
season = Season.AUTUMN
|
||||||
|
)
|
||||||
|
private val dayInFirstPeriod = Day(LocalDate.of(2026, 9, 15), emptyList())
|
||||||
|
private val dayInSecondPeriod = Day(LocalDate.of(2026, 9, 29), emptyList())
|
||||||
|
private val dayOutsidePeriods = Day(LocalDate.of(2026, 10, 20), emptyList())
|
||||||
|
|
||||||
|
private val schedule = Schedule(
|
||||||
|
groupId = "12345",
|
||||||
|
periods = listOf(autumnPeriod, nextPeriod),
|
||||||
|
days = listOf(dayInFirstPeriod, dayInSecondPeriod, dayOutsidePeriods)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun periodOf_matchesDateInsidePeriod() {
|
||||||
|
assertEquals(autumnPeriod, schedule.periodOf(LocalDate.of(2026, 9, 15)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun periodOf_includesBothBounds() {
|
||||||
|
assertEquals(autumnPeriod, schedule.periodOf(autumnPeriod.weekStart))
|
||||||
|
assertEquals(autumnPeriod, schedule.periodOf(autumnPeriod.weekEnd))
|
||||||
|
assertEquals(nextPeriod, schedule.periodOf(nextPeriod.weekStart))
|
||||||
|
assertEquals(nextPeriod, schedule.periodOf(nextPeriod.weekEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun periodOf_returnsNullForDateOutsideAllPeriods() {
|
||||||
|
assertNull(schedule.periodOf(LocalDate.of(2026, 10, 20)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun daysOf_returnsOnlyDaysWithinPeriod() {
|
||||||
|
assertEquals(listOf(dayInFirstPeriod), schedule.daysOf(autumnPeriod))
|
||||||
|
assertEquals(listOf(dayInSecondPeriod), schedule.daysOf(nextPeriod))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun daysOf_returnsEmptyWhenPeriodHasNoDays() {
|
||||||
|
val emptyPeriod = SchedulePeriod(
|
||||||
|
weekStart = LocalDate.of(2027, 1, 4),
|
||||||
|
weekEnd = LocalDate.of(2027, 1, 17),
|
||||||
|
season = Season.SPRING
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(emptyList<Day>(), schedule.daysOf(emptyPeriod))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun daysOf_neverReturnsDayOutsideEveryPeriod() {
|
||||||
|
val collected = schedule.periods.flatMap { schedule.daysOf(it) }
|
||||||
|
|
||||||
|
assertFalse(collected.contains(dayOutsidePeriods))
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
|||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<TABLE>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>День</SPAN></TD>
|
|
||||||
<TD><SPAN>Интервал</SPAN></TD>
|
|
||||||
<TD><SPAN>Лелекова Е.В.</SPAN></TD>
|
|
||||||
<TD><SPAN>Иванов И.И.</SPAN></TD>
|
|
||||||
<TD><SPAN>Петрова А.С.</SPAN></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD ROWSPAN=7><SPAN>понедельник 14.09.26</SPAN></TD>
|
|
||||||
<TD><SPAN>08:20-09:50</SPAN></TD>
|
|
||||||
<TD><SPAN>4-302 Биоразнообразие Лекция Бб-1101-04-00 Бб-1101-04-00, 01 подгруппа</SPAN></TD>
|
|
||||||
<TD><SPAN>4-308 Методика познания (учитель биологии) Практическое занятие ПОДб-4701-08-00</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>10:00-11:30</SPAN></TD>
|
|
||||||
<TD><SPAN>Бб-1101-04-00 Бб-1101-04-00, 01 подгруппа</SPAN></TD>
|
|
||||||
<TD><SPAN>Методическое совещание</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>12:00-13:30</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>14:00-15:30</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>15:40-17:10</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>17:15-18:45</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>18:55-20:25</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD ROWSPAN=7><SPAN>вторник 15.09.26</SPAN></TD>
|
|
||||||
<TD><SPAN>08:20-09:50</SPAN></TD>
|
|
||||||
<TD><SPAN>4-507 Биоразнообразие Лабораторная работа Бб-1102-04-00 Бб-1102-04-00, 01 подгруппа</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD><SPAN>1-116 Статистическая обработка экспериментальных данных Практическое занятие БТб-2101-03-00 БТб-2101-03-00, 02 подгруппа</SPAN></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>10:00-11:30</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD><SPAN>4-402a Фиторесурсы Лекция Бм-2102-03-00</SPAN></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>12:00-13:30</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>14:00-15:30</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>15:40-17:10</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>17:15-18:45</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR>
|
|
||||||
<TD><SPAN>18:55-20:25</SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
</TABLE>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,980 +0,0 @@
|
|||||||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 5.0 Transitional//EN">
|
|
||||||
<HTML>
|
|
||||||
<HEAD>
|
|
||||||
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; CHARSET=utf-8">
|
|
||||||
<TITLE></TITLE>
|
|
||||||
<STYLE TYPE="text/css">
|
|
||||||
body { background: #ffffff; margin: 0; font-family: Arial; font-size: 8pt; font-style: normal; }
|
|
||||||
tr.R0{ height: 25px; }
|
|
||||||
tr.R0 td.R0C0{ font-family: Arial; font-size: 14pt; font-style: normal; font-weight: bold; color: #000000; background-color: #ffffff; text-align: left; }
|
|
||||||
tr.R0 td.R0C1{ text-align: left; }
|
|
||||||
tr.R1{ height: 21px; }
|
|
||||||
tr.R1 td.R15C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R1 td.R15C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R1 td.R1C0{ font-family: Arial; font-size: 8pt; font-style: normal; font-weight: bold; color: #000000; background-color: #fcfaeb; text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R1 td.R1C2{ font-family: Arial; font-size: 12pt; font-style: normal; font-weight: bold; color: #000000; background-color: #fcfaeb; text-align: center; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R1 td.R9C0{ font-family: Arial; font-size: 14pt; font-style: normal; text-align: center; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R1 td.R9C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R1 td.R9C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R1 td.R9C6{ text-align: left; }
|
|
||||||
tr.R16{ height: 43px; }
|
|
||||||
tr.R16 td.R16C0{ font-family: Arial; font-size: 14pt; font-style: normal; text-align: center; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R16 td.R16C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R16 td.R16C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R16 td.R16C6{ text-align: left; }
|
|
||||||
tr.R17{ height: 141px; }
|
|
||||||
tr.R17 td.R17C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R17 td.R17C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R17 td.R17C6{ text-align: left; }
|
|
||||||
tr.R17 td.R58C0{ font-family: Arial; font-size: 14pt; font-style: normal; text-align: center; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R18{ height: 15px; }
|
|
||||||
tr.R18 td.R18C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R18 td.R18C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R18 td.R18C6{ text-align: left; }
|
|
||||||
tr.R18 td.R22C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R18 td.R22C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R18 td.R65C0{ font-family: Arial; font-size: 14pt; font-style: normal; text-align: center; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R2{ height: 30px; }
|
|
||||||
tr.R2 td.R2C0{ font-family: Arial; font-size: 14pt; font-style: normal; text-align: center; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R2 td.R2C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R2 td.R2C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R2 td.R2C6{ text-align: left; }
|
|
||||||
tr.R30{ height: 16px; }
|
|
||||||
tr.R30 td.R30C0{ font-family: Arial; font-size: 14pt; font-style: normal; text-align: center; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R30 td.R30C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R30 td.R30C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R30 td.R30C6{ text-align: left; }
|
|
||||||
tr.R30 td.R36C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R30 td.R36C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R37{ height: 24px; }
|
|
||||||
tr.R37 td.R37C0{ font-family: Arial; font-size: 14pt; font-style: normal; text-align: center; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R37 td.R37C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R37 td.R37C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R37 td.R37C6{ text-align: left; }
|
|
||||||
tr.R37 td.R43C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R37 td.R43C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R47{ height: 27px; }
|
|
||||||
tr.R47 td.R47C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R47 td.R47C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R47 td.R47C6{ text-align: left; }
|
|
||||||
tr.R49{ height: 26px; }
|
|
||||||
tr.R49 td.R49C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R49 td.R49C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R49 td.R49C6{ text-align: left; }
|
|
||||||
tr.R49 td.R50C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R49 td.R50C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R7{ height: 29px; }
|
|
||||||
tr.R7 td.R23C0{ font-family: Arial; font-size: 14pt; font-style: normal; text-align: center; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R7 td.R7C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R7 td.R7C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R7 td.R7C6{ text-align: left; }
|
|
||||||
tr.R7 td.R8C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R7 td.R8C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R74{ height: 23px; }
|
|
||||||
tr.R74 td.R74C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R74 td.R74C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 1px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R74 td.R74C6{ text-align: left; }
|
|
||||||
tr.R74 td.R78C1{ text-align: left; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R74 td.R78C2{ text-align: left; vertical-align: middle; border-left: #000000 1px solid; border-top: #000000 1px solid; border-bottom: #000000 3px solid; border-right: #000000 1px solid; }
|
|
||||||
tr.R86{ height: 99px; }
|
|
||||||
tr.R86 td.R86C3{ font-family: Arial; font-size: 8pt; font-style: normal; text-align: center; vertical-align: middle; }
|
|
||||||
table {table-layout: fixed; padding: 0px; padding-left: 2px; vertical-align:bottom; border-collapse:collapse;width: 100%; font-family: Arial; font-size: 8pt; font-style: normal; }
|
|
||||||
td { padding: 0px; padding-left: 2px; overflow:hidden; }
|
|
||||||
</STYLE>
|
|
||||||
</HEAD>
|
|
||||||
<BODY STYLE="background: #ffffff; margin: 0; font-family: Arial; font-size: 8pt; font-style: normal; ">
|
|
||||||
<TABLE style="width:100%; height:0px; " CELLSPACING=0>
|
|
||||||
<COL WIDTH=31>
|
|
||||||
<COL WIDTH=89>
|
|
||||||
<COL WIDTH=420>
|
|
||||||
<COL WIDTH=420>
|
|
||||||
<COL WIDTH=420>
|
|
||||||
<COL WIDTH=420>
|
|
||||||
<COL WIDTH=63>
|
|
||||||
<COL>
|
|
||||||
<TR CLASS=R0>
|
|
||||||
<TD CLASS="R0C0" COLSPAN=3><SPAN STYLE="white-space:nowrap;max-width:0px;">Обновление 07.10.2026</SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R1C0"><SPAN STYLE="white-space:nowrap;max-width:0px;">День</SPAN></TD>
|
|
||||||
<TD CLASS="R1C0"><SPAN STYLE="white-space:nowrap;max-width:0px;">Интервал</SPAN></TD>
|
|
||||||
<TD CLASS="R1C2"><SPAN STYLE="white-space:nowrap;max-width:0px;">Долженкова М.Л.</SPAN></TD>
|
|
||||||
<TD CLASS="R1C2"><SPAN STYLE="white-space:nowrap;max-width:0px;">Коржавина А.С.</SPAN></TD>
|
|
||||||
<TD CLASS="R1C2"><SPAN STYLE="white-space:nowrap;max-width:0px;">Мельцов В.Ю.</SPAN></TD>
|
|
||||||
<TD CLASS="R1C2"><SPAN STYLE="white-space:nowrap;max-width:0px;">Прозоров Д.Е.</SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R2>
|
|
||||||
<TD CLASS="R2C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">понедельник 28.09.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R2C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R2>
|
|
||||||
<TD CLASS="R2C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R2C2">1-109 Программирование Лабораторная работа ИВТб-1301-06-00 ИВТб-1301-06-00, 01 подгруппа</TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R2>
|
|
||||||
<TD CLASS="R2C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R2C2">1-109 Программирование Лабораторная работа ИВТб-1301-06-00 ИВТб-1301-06-00, 01 подгруппа</TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R2>
|
|
||||||
<TD CLASS="R2C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R2C2">1-109 Программирование Лабораторная работа ИВТб-1302-06-00 ИВТб-1302-06-00, 01 подгруппа</TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R2>
|
|
||||||
<TD CLASS="R2C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R2C2">1-109 Программирование Лабораторная работа ИВТб-1302-06-00 ИВТб-1302-06-00, 01 подгруппа</TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R2C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R8C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R8C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R8C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R8C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R8C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R9C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">вторник 29.09.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R9C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R9C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-237 Теория автоматов Практическое занятие ИВТб-2301-06-00 ИВТб-2301-06-00, 01 подгруппа ИВТб-2301-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-237 Теория автоматов Практическое занятие ИВТб-2302-06-00 ИВТб-2302-06-00, 01 подгруппа ИВТб-2302-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R9C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R9C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R15C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R15C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R15C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R15C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R15C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R16>
|
|
||||||
<TD CLASS="R16C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">среда 30.09.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R16C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R16C2">2-207 Программирование Практическое занятие ИВТб-1306-06-00 ИВТб-1306-06-00, 01 подгруппа ИВТб-1306-06-00, 02 подгруппа ИВТб-1306-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R17>
|
|
||||||
<TD CLASS="R17C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2">2-209 Информатика Лекция ИВТб-1301-06-00 ИВТб-1301-06-00, 01 подгруппа ИВТб-1301-06-00, 02 подгруппа ИВТб-1301-06-00, 03 подгруппа ИВТб-1302-06-00 ИВТб-1302-06-00, 01 подгруппа ИВТб-1302-06-00, 02 подгруппа ИВТб-1302-06-00, 03 подгруппа ИВТб-1303-06-00 ИВТб-1303-06-00, 01 подгруппа ИВТб-1303-06-00, 02 подгруппа ИВТб-1303-06-00, 03 подгруппа ИВТб-1304-06-00 ИВТб-1304-06-00, 01 подгруппа ИВТб-1304-06-00, 02 подгруппа ИВТб-1304-06-00, 03 подгруппа ИВТб-1305-06-00 ИВТб-1305-06-00, 01 подгруппа ИВТб-1305-06-00, 02 подгруппа ИВТб-1305-06-00, 03 подгруппа ИВТб-1306-06-00 ИВТб-1306-06-00, 01 подгруппа ИВТб-1306-06-00, 02 подгруппа ИВТб-1306-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R16>
|
|
||||||
<TD CLASS="R16C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R16C2">1-334 Программирование Практическое занятие ИВТб-1305-06-00 ИВТб-1305-06-00, 01 подгруппа ИВТб-1305-06-00, 02 подгруппа ИВТб-1305-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R22C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R23C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">четверг 01.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-237 Интеллектуальные системы Лабораторная работа ИВТб-4301-05-00 ИВТб-4301-05-00, 01 подгруппа ИВТб-4301-05-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-237 Интеллектуальные системы Лабораторная работа ИВТб-4301-05-00 ИВТб-4301-05-00, 01 подгруппа ИВТб-4301-05-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R17>
|
|
||||||
<TD CLASS="R17C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R17C2">2-209 Программирование Лекция ИВТб-1301-06-00 ИВТб-1301-06-00, 01 подгруппа ИВТб-1301-06-00, 02 подгруппа ИВТб-1301-06-00, 03 подгруппа ИВТб-1302-06-00 ИВТб-1302-06-00, 01 подгруппа ИВТб-1302-06-00, 02 подгруппа ИВТб-1302-06-00, 03 подгруппа ИВТб-1303-06-00 ИВТб-1303-06-00, 01 подгруппа ИВТб-1303-06-00, 02 подгруппа ИВТб-1303-06-00, 03 подгруппа ИВТб-1304-06-00 ИВТб-1304-06-00, 01 подгруппа ИВТб-1304-06-00, 02 подгруппа ИВТб-1304-06-00, 03 подгруппа ИВТб-1305-06-00 ИВТб-1305-06-00, 01 подгруппа ИВТб-1305-06-00, 02 подгруппа ИВТб-1305-06-00, 03 подгруппа ИВТб-1306-06-00 ИВТб-1306-06-00, 01 подгруппа ИВТб-1306-06-00, 02 подгруппа ИВТб-1306-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R22C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R30>
|
|
||||||
<TD CLASS="R30C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">пятница 02.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R30C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R30>
|
|
||||||
<TD CLASS="R30C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R16>
|
|
||||||
<TD CLASS="R16C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R16C2">1-435 Программирование Практическое занятие ИВТб-1301-06-00 ИВТб-1301-06-00, 01 подгруппа ИВТб-1301-06-00, 02 подгруппа ИВТб-1301-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-109 Программирование Лабораторная работа ИВТб-1301-06-00 ИВТб-1301-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-109 Программирование Лабораторная работа ИВТб-1301-06-00 ИВТб-1301-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-237 Теория автоматов Практическое занятие ИВТб-2304-06-00 ИВТб-2304-06-00, 01 подгруппа ИВТб-2304-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R30>
|
|
||||||
<TD CLASS="R30C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R30>
|
|
||||||
<TD CLASS="R36C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R36C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R36C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R36C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R36C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R30C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">суббота 03.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R43C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R43C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R43C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R43C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R43C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R16>
|
|
||||||
<TD CLASS="R16C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">понедельник 05.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R16C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R16C2">1-311 Программирование Практическое занятие ИВТб-1302-06-00 ИВТб-1302-06-00, 01 подгруппа ИВТб-1302-06-00, 02 подгруппа ИВТб-1302-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-109 Программирование Лабораторная работа ИВТб-1302-06-00 ИВТб-1302-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-109 Программирование Лабораторная работа ИВТб-1302-06-00 ИВТб-1302-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R47>
|
|
||||||
<TD CLASS="R47C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R47C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R47C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R47C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R47C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R47C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R47>
|
|
||||||
<TD CLASS="R47C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R47C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R47C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R47C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R47C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R47C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R49>
|
|
||||||
<TD CLASS="R49C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R49C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R49C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R49C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R49C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R49C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R49>
|
|
||||||
<TD CLASS="R50C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R50C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R50C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R50C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R50C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R49C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R23C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">вторник 06.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-237 Теория автоматов Практическое занятие ИВТб-2305-06-00 ИВТб-2305-06-00, 01 подгруппа ИВТб-2305-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R9C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R9C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R9C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">1-237 Теория автоматов Практическое занятие ИВТб-2303-06-00 ИВТб-2303-06-00, 01 подгруппа ИВТб-2303-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R9C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R1>
|
|
||||||
<TD CLASS="R15C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R15C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R15C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R15C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R15C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R9C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R17>
|
|
||||||
<TD CLASS="R58C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">среда 07.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R17C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R17C2">2-209 Программирование Лекция ИВТб-1301-06-00 ИВТб-1301-06-00, 01 подгруппа ИВТб-1301-06-00, 02 подгруппа ИВТб-1301-06-00, 03 подгруппа ИВТб-1302-06-00 ИВТб-1302-06-00, 01 подгруппа ИВТб-1302-06-00, 02 подгруппа ИВТб-1302-06-00, 03 подгруппа ИВТб-1303-06-00 ИВТб-1303-06-00, 01 подгруппа ИВТб-1303-06-00, 02 подгруппа ИВТб-1303-06-00, 03 подгруппа ИВТб-1304-06-00 ИВТб-1304-06-00, 01 подгруппа ИВТб-1304-06-00, 02 подгруппа ИВТб-1304-06-00, 03 подгруппа ИВТб-1305-06-00 ИВТб-1305-06-00, 01 подгруппа ИВТб-1305-06-00, 02 подгруппа ИВТб-1305-06-00, 03 подгруппа ИВТб-1306-06-00 ИВТб-1306-06-00, 01 подгруппа ИВТб-1306-06-00, 02 подгруппа ИВТб-1306-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R17>
|
|
||||||
<TD CLASS="R17C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R17C2">2-209 Введение в профессию Лекция ИВТб-1301-06-00 ИВТб-1301-06-00, 01 подгруппа ИВТб-1301-06-00, 02 подгруппа ИВТб-1301-06-00, 03 подгруппа ИВТб-1302-06-00 ИВТб-1302-06-00, 01 подгруппа ИВТб-1302-06-00, 02 подгруппа ИВТб-1302-06-00, 03 подгруппа ИВТб-1303-06-00 ИВТб-1303-06-00, 01 подгруппа ИВТб-1303-06-00, 02 подгруппа ИВТб-1303-06-00, 03 подгруппа ИВТб-1304-06-00 ИВТб-1304-06-00, 01 подгруппа ИВТб-1304-06-00, 02 подгруппа ИВТб-1304-06-00, 03 подгруппа ИВТб-1305-06-00 ИВТб-1305-06-00, 01 подгруппа ИВТб-1305-06-00, 02 подгруппа ИВТб-1305-06-00, 03 подгруппа ИВТб-1306-06-00 ИВТб-1306-06-00, 01 подгруппа ИВТб-1306-06-00, 02 подгруппа ИВТб-1306-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R17C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R22C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R65C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">четверг 08.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R16>
|
|
||||||
<TD CLASS="R16C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R16C2">1-435 Программирование Практическое занятие ИВТб-1303-06-00 ИВТб-1303-06-00, 01 подгруппа ИВТб-1303-06-00, 02 подгруппа ИВТб-1303-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R16>
|
|
||||||
<TD CLASS="R16C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R16C2">1-435 Программирование Практическое занятие ИВТб-1304-06-00 ИВТб-1304-06-00, 01 подгруппа ИВТб-1304-06-00, 02 подгруппа ИВТб-1304-06-00, 03 подгруппа</TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R16C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R7>
|
|
||||||
<TD CLASS="R7C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C2">2-205 Теория автоматов Практическое занятие ИВТб-2306-06-00 ИВТб-2306-06-00, 01 подгруппа ИВТб-2306-06-00, 02 подгруппа</TD>
|
|
||||||
<TD CLASS="R7C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R7C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R18C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R18>
|
|
||||||
<TD CLASS="R22C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R22C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R18C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">пятница 09.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R74>
|
|
||||||
<TD CLASS="R74C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R74>
|
|
||||||
<TD CLASS="R74C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R74>
|
|
||||||
<TD CLASS="R74C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R74>
|
|
||||||
<TD CLASS="R74C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R74>
|
|
||||||
<TD CLASS="R78C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R78C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R78C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R78C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R78C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R74C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C0" ROWSPAN=7><div style="margin: 0 -17px;-moz-transform: rotate(-90deg); -webkit-transform: rotate(-90deg); -o-transform: rotate(-90deg); -ms-transform: rotate(-90deg); transform: rotate(-90deg)"><SPAN STYLE="max-width:0px;">суббота 10.10.26 </SPAN></TD>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">08:20-09:50</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">10:00-11:30</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">11:45-13:15</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">14:00-15:30</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">15:45-17:15</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">17:20-18:50</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R37>
|
|
||||||
<TD CLASS="R37C1"><SPAN STYLE="white-space:nowrap;max-width:0px;">18:55-20:25</SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C2"><SPAN></SPAN></TD>
|
|
||||||
<TD CLASS="R37C6"><SPAN></SPAN></TD>
|
|
||||||
<TD><SPAN></SPAN></TD>
|
|
||||||
<TD></TD>
|
|
||||||
</TR>
|
|
||||||
<TR CLASS=R86>
|
|
||||||
<TD><DIV STYLE="position:relative; height:99px;width: 100%; overflow:hidden;"><SPAN></SPAN></DIV></TD>
|
|
||||||
<TD><DIV STYLE="position:relative; height:99px;width: 100%; overflow:hidden;"><SPAN></SPAN></DIV></TD>
|
|
||||||
<TD><DIV STYLE="position:relative; height:99px;width: 100%; overflow:hidden;"><SPAN></SPAN></DIV></TD>
|
|
||||||
<TD CLASS="R86C3"><DIV STYLE="position:relative; height:99px;width: 100%; overflow:hidden;"><SPAN></SPAN></DIV></TD>
|
|
||||||
<TD><DIV STYLE="position:relative; height:99px;width: 100%; overflow:hidden;"><SPAN></SPAN></DIV></TD>
|
|
||||||
<TD><DIV STYLE="position:relative; height:99px;width: 100%; overflow:hidden;"><SPAN></SPAN></DIV></TD>
|
|
||||||
<TD><DIV STYLE="position:relative; height:99px;width: 100%; overflow:hidden;"><SPAN></SPAN></DIV></TD>
|
|
||||||
<TD><DIV STYLE="position:relative; height:99px;width: 100%; overflow:hidden;"><SPAN></SPAN></DIV></TD>
|
|
||||||
<TD><DIV STYLE="width:100%;height:99px;overflow:hidden;"> </DIV></TD>
|
|
||||||
</TR>
|
|
||||||
</TABLE>
|
|
||||||
</BODY>
|
|
||||||
</HTML>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"><html xmlns="http://www.w3.org/1999/xhtml">
|
|
||||||
<head>
|
|
||||||
<meta http-equiv="content-language" content="ru">
|
|
||||||
<title>Расписание занятий преподавателей - Официальный сайт ВятГУ</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="headerEduPrograms">
|
|
||||||
<div class="block-content">
|
|
||||||
<h2>Планируемое расписание занятий преподавателей</h2>
|
|
||||||
<div class="fak_name" data-fak_id="fak_avt_41"><span>Факультет автоматики</span> и вычислительной техники (ОРУ)</div>
|
|
||||||
<div class="block-content" id="fak_avt_41" style="display: none;">
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div
|
|
||||||
class="kafPeriod"
|
|
||||||
data-kaf_period_id="111111"
|
|
||||||
>
|
|
||||||
Кафедра алгоритмов и программ </div>
|
|
||||||
<div
|
|
||||||
class="listPeriod"
|
|
||||||
id="listPeriod_111111"
|
|
||||||
style="display: none;"
|
|
||||||
>
|
|
||||||
<a target="_blank" href="/reports/schedule/prepod/11111_1_01092026_13092026.html">с 01 09 2026 по 13 09 2026</a>
|
|
||||||
<br/>
|
|
||||||
<a target="_blank" href="/reports/schedule/prepod/11111_1_14092026_27092026.html">с 14 09 2026 по 27 09 2026</a>
|
|
||||||
<br/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div
|
|
||||||
class="kafPeriod"
|
|
||||||
data-kaf_period_id="222221"
|
|
||||||
>
|
|
||||||
Кафедра <b>технологий и конструирования</b> машин и аппаратов </div>
|
|
||||||
<div
|
|
||||||
class="listPeriod"
|
|
||||||
id="listPeriod_222221"
|
|
||||||
style="display: none;"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="fak_name" data-fak_id="fak_phil_21"><b>Философский</b> факультет</div>
|
|
||||||
<div class="block-content" id="fak_phil_21" style="display: none;">
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="kafPeriod" data-kaf_period_id="333331">
|
|
||||||
Кафедра философии </div>
|
|
||||||
<div
|
|
||||||
class="listPeriod"
|
|
||||||
id="listPeriod_333331"
|
|
||||||
style="display: none;"
|
|
||||||
>
|
|
||||||
<a target="_blank" href="/reports/schedule/prepod/33333_1_01092026_13092026.html">с 01 09 2026 по 13 09 2026</a>
|
|
||||||
<br/>
|
|
||||||
<a target="_blank" href="/reports/schedule/prepod/33333_1_14092026_27092026.html">с 14 09 2026 по 27 09 2026</a>
|
|
||||||
<br/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div
|
|
||||||
class="kafPeriod"
|
|
||||||
data-kaf_period_id="444441"
|
|
||||||
>
|
|
||||||
Кафедра логики и методологии науки </div>
|
|
||||||
<div
|
|
||||||
class="listPeriod"
|
|
||||||
id="listPeriod_444441"
|
|
||||||
style="display: none;"
|
|
||||||
>
|
|
||||||
<a target="_blank" href="/reports/schedule/prepod/44444_2_02022026_27022026.html">с 02 02 2027 по 27 02 2026</a>
|
|
||||||
<br/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -3,7 +3,7 @@ agp = "9.1.1"
|
|||||||
coreKtx = "1.10.1"
|
coreKtx = "1.10.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.1.5"
|
junitVersion = "1.1.5"
|
||||||
espressoCore = "3.5.1"
|
runner = "1.5.2"
|
||||||
lifecycleRuntimeKtx = "2.6.1"
|
lifecycleRuntimeKtx = "2.6.1"
|
||||||
activityCompose = "1.8.0"
|
activityCompose = "1.8.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
@@ -22,7 +22,7 @@ monitor = "1.8.0"
|
|||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
@@ -30,8 +30,6 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
|||||||
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" }
|
||||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|||||||
Reference in New Issue
Block a user