Compare commits

...
Author SHA1 Message Date
vantablackTea 84da62c45d optimisation: baked jdk and sdk into runner 2026-10-08 16:56:27 +03:00
Agzamus 57e5bd9af8 feat: department display 2026-10-08 02:10:26 +03:00
Agzamus c7298c0c84 fix: wrong parsed teacher schedule 2026-10-08 01:42:26 +03:00
Agzamus ae0fea1ea6 fix: ssl error & offline teacher schedule 2026-10-07 21:55:53 +03:00
Agzamus af1f374b95 Обновить README.md 2026-09-20 17:42:05 +03:00
Agzamus 4f191e9f2f ui: group display istead of teacher in teacher schedule 2026-09-20 15:30:10 +03:00
Agzamus e16454a196 fix(teacher): group day slots by real lesson start time
slotIndex is a per-department index of non-empty intervals, not a time.
Merging all departments of a period by slotIndex and deriving the slot
time from the first inserted lesson produced shifted times (e.g. Meltsov
10:00 shown at 11:45) depending on sync order. Group by lesson start
minute instead so each department's real times are kept.

Add debug logging for teacher schedule sync/build and debounce teacher
search in picker.
2026-09-20 15:13:27 +03:00
Agzamus ef81659521 chore(http): log failed requests only
Per-request GET success logs (39 long URLs per teacher sync) flood the
2MB logcat buffer together with system ZeroHung spam, drowning the real
kill reason (am_proc_died / lowmemorykiller). Log only non-2xx responses.
2026-09-20 03:08:33 +03:00
Agzamus 6189540acf perf(teacher): lower sync memory footprint
- Reduce sync parallelism 5 -> 2 to cap peak heap while bulk-fetching
  all departments for a period
- Replace per-write rows (insert schedule/days/lessons separately) with a
  single @Transaction replaceSchedule() so Room invalidates observers once
  per department instead of ~4x each, cutting main-thread recompositions
  and intermediate allocations during the 38-department sync
2026-09-20 02:43:54 +03:00
Agzamus 1d55e84e96 fix(teacher): register migration, containment period queries, concurrency guards
- Register AppDatabase.MIGRATION_3_4 so v3 databases upgrade without data loss
- Query teacher schedule periods by containment (weekStart<=x AND weekEnd>=x)
  instead of exact weekStart, matching 2-week catalog windows
- Clip buildTeacherSchedule days to the requested week range
- Add weeksFor() to enumerate weeks of a catalog window for the UI selector
- Serialize syncPeriod body with a Mutex to protect the unique index
- Rethrow CancellationException before generic catch; version loadRevision
  checks so stale observers/loads are cancelled
- Sync job tracking in PickerViewModel; keep loading true while empty
- Drop dead selectTeacher helper
2026-09-19 23:32:09 +03:00
Agzamus 39e4f5a04f fix(remote): replace unsupported (?U)\b regex for Android runtime 2026-09-19 20:44:22 +03:00
Agzamus e1b2f05548 feat(ui): integrate teacher mode into AppRoot and Home 2026-09-19 20:28:13 +03:00
Agzamus f5696fbbd6 feat(ui): add combined group/teacher picker with segments 2026-09-19 20:16:41 +03:00
Agzamus b9b3a18f0c feat(di): wire teacher repositories into AppContainer 2026-09-19 19:37:56 +03:00
Agzamus 2dd8b8c469 feat(ui): add teacher schedule screen and view model 2026-09-19 19:12:19 +03:00
Agzamus 6c06892d9a feat(data): add TeacherScheduleRepositoryImpl with bulk period sync 2026-09-19 19:12:19 +03:00
Agzamus 9dce30931b feat(data): add TeacherRepositoryImpl 2026-09-19 15:59:25 +03:00
Agzamus 32c2b60afa feat(remote): add jsoup TeacherScheduleParser 2026-09-19 14:47:55 +03:00
Agzamus 458e4c4e69 feat(remote): add streaming TeachersParser 2026-09-19 14:47:46 +03:00
Agzamus 1a4bff73d0 feat(db): add teacher schedule tables and Room v4 migration 2026-09-19 14:47:35 +03:00
Agzamus a5ea516713 refactor(ui): extract shared schedule composables 2026-09-19 01:47:29 +03:00
Agzamus 17d437aace feat(domain): add teacher models and repository interfaces 2026-09-19 01:43:17 +03:00
vantablackTea 1077be1897 fix: possible moshi deleting due to proguard 2026-09-18 11:36:25 +03:00
61 changed files with 25116 additions and 670 deletions
+2 -33
View File
@@ -18,50 +18,19 @@ 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: Set up JDK 17 - name: Verify Environment
# Ставим 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
javac -version echo "SDK path: $ANDROID_HOME"
- 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
+7 -1
View File
@@ -1,2 +1,8 @@
# vyatsu-schedule # ВятГУ расписание _(неофициальный Android клиент)_
Расписание загружается и парсится прямиком с сайта ВятГУ, и сохраняется в локальную БД телефона без промежуточных серверов
- Расписание групп - парсится из PDF-ок ([тута](https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/raspisanie-zanyatiy-dlya-studentov.html))
- Расписание преподавателей - загружаются все расписания преподавателей [отсюда](https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/teacher.html) на выбранный переиод (ибо раскиданы по кафедрам)
+10
View File
@@ -28,3 +28,13 @@
# Запретить R8 удалять и переименовывать внутренности PdfBox-Android # Запретить R8 удалять и переименовывать внутренности PdfBox-Android
-keep class com.tom_roush.pdfbox.** { *; } -keep class com.tom_roush.pdfbox.** { *; }
-keep interface com.tom_roush.pdfbox.** { *; } -keep interface com.tom_roush.pdfbox.** { *; }
# Moshi удаляется proguard
-keepnames @com.squareup.moshi.JsonClass class *
-keep class **JsonAdapter {
public <init>(...);
<fields>;
}
-keepclassmembers @com.squareup.moshi.JsonClass class * {
synthetic <init>(...);
}
@@ -0,0 +1,818 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "38abab3f8c5b39625b6a4de9d58968ca",
"entities": [
{
"tableName": "groups",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "faculty",
"columnName": "faculty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "group_periods",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_group_periods_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_periods_groupId` ON `${TABLE_NAME}` (`groupId`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "schedules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`groupId`) REFERENCES `groups`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_schedules_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_schedules_groupId` ON `${TABLE_NAME}` (`groupId`)"
},
{
"name": "index_schedules_groupId_season_weekStart",
"unique": true,
"columnNames": [
"groupId",
"season",
"weekStart"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_schedules_groupId_season_weekStart` ON `${TABLE_NAME}` (`groupId`, `season`, `weekStart`)"
}
],
"foreignKeys": [
{
"table": "groups",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "days",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleId",
"columnName": "scheduleId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_days_scheduleId",
"unique": false,
"columnNames": [
"scheduleId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
},
{
"name": "index_days_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_days_date` ON `${TABLE_NAME}` (`date`)"
}
],
"foreignKeys": [
{
"table": "schedules",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"scheduleId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "lessons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT, FOREIGN KEY(`dayId`) REFERENCES `days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayId",
"columnName": "dayId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slotIndex",
"columnName": "slotIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lessonIndex",
"columnName": "lessonIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT"
},
{
"fieldPath": "groupName",
"columnName": "groupName",
"affinity": "TEXT"
},
{
"fieldPath": "subgroup",
"columnName": "subgroup",
"affinity": "TEXT"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_lessons_dayId",
"unique": false,
"columnNames": [
"dayId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
},
{
"name": "index_lessons_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_date` ON `${TABLE_NAME}` (`date`)"
},
{
"name": "index_lessons_subject",
"unique": false,
"columnNames": [
"subject"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
},
{
"name": "index_lessons_room",
"unique": false,
"columnNames": [
"room"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_lessons_room` ON `${TABLE_NAME}` (`room`)"
}
],
"foreignKeys": [
{
"table": "days",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"dayId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "preferences",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
}
},
{
"tableName": "departments",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`departmentId` TEXT NOT NULL, `name` TEXT NOT NULL, `faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, PRIMARY KEY(`departmentId`))",
"fields": [
{
"fieldPath": "departmentId",
"columnName": "departmentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "faculty",
"columnName": "faculty",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"departmentId"
]
}
},
{
"tableName": "department_periods",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "departmentId",
"columnName": "departmentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_department_periods_departmentId",
"unique": false,
"columnNames": [
"departmentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_department_periods_departmentId` ON `${TABLE_NAME}` (`departmentId`)"
}
],
"foreignKeys": [
{
"table": "departments",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"departmentId"
],
"referencedColumns": [
"departmentId"
]
}
]
},
{
"tableName": "teacher_schedules",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, `weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, `lastSyncedAt` INTEGER NOT NULL, FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "departmentId",
"columnName": "departmentId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "season",
"columnName": "season",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "weekStart",
"columnName": "weekStart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "weekEnd",
"columnName": "weekEnd",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastSyncedAt",
"columnName": "lastSyncedAt",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_teacher_schedules_departmentId",
"unique": false,
"columnNames": [
"departmentId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId` ON `${TABLE_NAME}` (`departmentId`)"
},
{
"name": "index_teacher_schedules_departmentId_season_weekStart",
"unique": true,
"columnNames": [
"departmentId",
"season",
"weekStart"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId_season_weekStart` ON `${TABLE_NAME}` (`departmentId`, `season`, `weekStart`)"
}
],
"foreignKeys": [
{
"table": "departments",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"departmentId"
],
"referencedColumns": [
"departmentId"
]
}
]
},
{
"tableName": "teacher_days",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, FOREIGN KEY(`scheduleId`) REFERENCES `teacher_schedules`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scheduleId",
"columnName": "scheduleId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_teacher_days_scheduleId",
"unique": false,
"columnNames": [
"scheduleId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_days_scheduleId` ON `${TABLE_NAME}` (`scheduleId`)"
},
{
"name": "index_teacher_days_date",
"unique": false,
"columnNames": [
"date"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_days_date` ON `${TABLE_NAME}` (`date`)"
}
],
"foreignKeys": [
{
"table": "teacher_schedules",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"scheduleId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "teacher_lessons",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, `slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, `raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, `subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, `teacher` TEXT NOT NULL, FOREIGN KEY(`dayId`) REFERENCES `teacher_days`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dayId",
"columnName": "dayId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "slotIndex",
"columnName": "slotIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "start",
"columnName": "start",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "end",
"columnName": "end",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lessonIndex",
"columnName": "lessonIndex",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "raw",
"columnName": "raw",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "room",
"columnName": "room",
"affinity": "TEXT"
},
{
"fieldPath": "groupName",
"columnName": "groupName",
"affinity": "TEXT"
},
{
"fieldPath": "subgroup",
"columnName": "subgroup",
"affinity": "TEXT"
},
{
"fieldPath": "subject",
"columnName": "subject",
"affinity": "TEXT"
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "teacher",
"columnName": "teacher",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_teacher_lessons_dayId",
"unique": false,
"columnNames": [
"dayId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_dayId` ON `${TABLE_NAME}` (`dayId`)"
},
{
"name": "index_teacher_lessons_teacher",
"unique": false,
"columnNames": [
"teacher"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_teacher` ON `${TABLE_NAME}` (`teacher`)"
},
{
"name": "index_teacher_lessons_room",
"unique": false,
"columnNames": [
"room"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_room` ON `${TABLE_NAME}` (`room`)"
},
{
"name": "index_teacher_lessons_subject",
"unique": false,
"columnNames": [
"subject"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_teacher_lessons_subject` ON `${TABLE_NAME}` (`subject`)"
}
],
"foreignKeys": [
{
"table": "teacher_days",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"dayId"
],
"referencedColumns": [
"id"
]
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '38abab3f8c5b39625b6a4de9d58968ca')"
]
}
}
+1
View File
@@ -12,6 +12,7 @@
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">
+23 -9
View File
@@ -1,11 +1,12 @@
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
@@ -23,9 +24,11 @@ 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) {
@@ -51,18 +54,21 @@ fun AppRoot() {
) )
} }
if (state.noGroup) { if (state.noGroup && teacherState.noTeacher) {
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container)) val pickerViewModel: PickerViewModel = viewModel(factory = pickerFactory(container))
GroupPickerScreen( PickerScreen(
viewModel = groupPickerViewModel, viewModel = pickerViewModel,
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() }
) )
} }
@@ -72,5 +78,13 @@ 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 groupPickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { GroupPickerViewModel(c.groupRepository) } private fun teacherScheduleFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { TeacherScheduleViewModel(c.teacherScheduleRepository, c.preferencesRepository) }
private fun pickerFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory {
PickerViewModel(
c.groupRepository,
c.teacherRepository,
c.teacherScheduleRepository,
c.preferencesRepository
)
}
private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) } private fun updateFactory(c: AppContainer): ViewModelProvider.Factory = SimpleFactory { UpdateViewModel(c.appUpdater, c.preferencesRepository) }
@@ -3,6 +3,8 @@ 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 = [
@@ -11,9 +13,14 @@ import androidx.room.TypeConverters
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 = 3, version = 4,
exportSchema = true exportSchema = true
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@@ -21,4 +28,91 @@ 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`)"
)
}
}
}
} }
@@ -0,0 +1,12 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "departments")
data class DepartmentEntity(
@PrimaryKey val departmentId: String,
val name: String,
val faculty: String,
val lastSyncedAt: Long
)
@@ -0,0 +1,28 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
// Все опубликованные на сайте 2-недельные периоды кафедры, включая ещё не начавшиеся.
@Entity(
tableName = "department_periods",
foreignKeys = [
ForeignKey(
entity = DepartmentEntity::class,
parentColumns = ["departmentId"],
childColumns = ["departmentId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("departmentId")]
)
data class DepartmentPeriodEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val departmentId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long
)
@@ -28,6 +28,36 @@ 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?
@@ -0,0 +1,39 @@
package agzam.vyatsu.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface TeacherDao {
@Query("SELECT * FROM departments ORDER BY name")
fun observeDepartments(): Flow<List<DepartmentEntity>>
@Query("SELECT * FROM departments WHERE departmentId = :id")
suspend fun getDepartment(id: String): DepartmentEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(departments: List<DepartmentEntity>)
@Query("SELECT MAX(lastSyncedAt) FROM departments")
suspend fun maxSyncedAt(): Long?
@Query("SELECT DISTINCT faculty FROM departments ORDER BY faculty")
suspend fun getFaculties(): List<String>
@Query("DELETE FROM department_periods")
suspend fun deleteAllPeriods()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPeriods(periods: List<DepartmentPeriodEntity>)
@Query(
"SELECT * FROM department_periods WHERE weekStart <= :epochDay AND weekEnd >= :epochDay ORDER BY departmentId"
)
suspend fun getPeriodsForDate(epochDay: Long): List<DepartmentPeriodEntity>
@Query("SELECT * FROM department_periods WHERE departmentId = :departmentId ORDER BY weekStart")
suspend fun getPeriodsForDepartment(departmentId: String): List<DepartmentPeriodEntity>
}
@@ -0,0 +1,27 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_days",
foreignKeys = [
ForeignKey(
entity = TeacherScheduleEntity::class,
parentColumns = ["id"],
childColumns = ["scheduleId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("scheduleId"),
Index("date")
]
)
data class TeacherDayEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val scheduleId: Long,
val date: Long
)
@@ -0,0 +1,41 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_lessons",
foreignKeys = [
ForeignKey(
entity = TeacherDayEntity::class,
parentColumns = ["id"],
childColumns = ["dayId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("dayId"),
Index("teacher"),
Index("room"),
Index("subject")
]
)
data class TeacherLessonEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val dayId: Long,
val date: Long,
val slotIndex: Int,
val start: Int,
val end: Int,
val lessonIndex: Int,
val raw: String,
val room: String?,
val groupName: String?,
val subgroup: String?,
val subject: String?,
val type: LessonType,
val teacher: String
)
@@ -0,0 +1,10 @@
package agzam.vyatsu.data.local
import androidx.room.Embedded
/** Занятие преподавателя + кафедра-источник. Проекция LEFT JOIN, схема Room не меняется. */
data class TeacherLessonWithDepartment(
@Embedded val lesson: TeacherLessonEntity,
val departmentId: String,
val departmentName: String?
)
@@ -0,0 +1,87 @@
package agzam.vyatsu.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface TeacherScheduleDao {
@Query("SELECT * FROM teacher_schedules ORDER BY weekStart DESC")
fun observeSchedules(): Flow<List<TeacherScheduleEntity>>
@Query("SELECT * FROM teacher_schedules ORDER BY weekStart DESC")
suspend fun getSchedules(): List<TeacherScheduleEntity>
@Query("DELETE FROM teacher_days WHERE scheduleId = :scheduleId")
suspend fun deleteDaysForSchedule(scheduleId: Long)
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertSchedule(schedule: TeacherScheduleEntity): Long
@Query(
"DELETE FROM teacher_schedules WHERE departmentId = :departmentId AND season = :season AND weekStart = :weekStart"
)
suspend fun deleteSchedule(departmentId: String, season: String, weekStart: Long)
@Query("DELETE FROM teacher_schedules WHERE season = :season AND weekStart = :weekStart")
suspend fun deletePeriod(season: String, weekStart: Long)
@Query("DELETE FROM teacher_schedules")
suspend fun deleteAll()
@Query("SELECT lastSyncedAt FROM teacher_schedules WHERE departmentId = :departmentId AND season = :season AND weekStart = :weekStart")
suspend fun lastSyncedAt(departmentId: String, season: String, weekStart: Long): Long?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDays(days: List<TeacherDayEntity>): List<Long>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLessons(lessons: List<TeacherLessonEntity>)
@Transaction
suspend fun replaceSchedule(schedule: TeacherScheduleEntity, days: List<TeacherDayEntity>, lessons: List<TeacherLessonEntity>) {
deleteSchedule(schedule.departmentId, schedule.season.name, schedule.weekStart)
val scheduleId = insertSchedule(schedule)
val daysWithScheduleId = days.map { it.copy(scheduleId = scheduleId) }
deleteDaysForSchedule(scheduleId)
val dayIds = insertDays(daysWithScheduleId)
val dayIdByDate = daysWithScheduleId.zip(dayIds).associate { (day, id) -> day.date to id }
insertLessons(lessons.map { lesson -> lesson.copy(dayId = dayIdByDate.getValue(lesson.date)) })
}
@Query(
"SELECT teacher_days.* FROM teacher_days INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher_days.date"
)
fun observeDaysForPeriod(season: String, weekStart: Long): Flow<List<TeacherDayEntity>>
@Query(
"SELECT teacher_lessons.*, teacher_schedules.departmentId AS departmentId, departments.name AS departmentName " +
"FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"LEFT JOIN departments ON departments.departmentId = teacher_schedules.departmentId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart " +
"ORDER BY teacher_lessons.date, teacher_lessons.slotIndex, teacher_lessons.lessonIndex"
)
fun observeLessonsForPeriod(season: String, weekStart: Long): Flow<List<TeacherLessonWithDepartment>>
@Query(
"SELECT DISTINCT teacher FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher"
)
fun observeDistinctTeachers(season: String, weekStart: Long): Flow<List<String>>
@Query(
"SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season " +
"AND weekStart <= :weekStart AND weekEnd >= :weekStart)"
)
suspend fun hasSchedulesForPeriod(season: String, weekStart: Long): Boolean
}
@@ -0,0 +1,31 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_schedules",
foreignKeys = [
ForeignKey(
entity = DepartmentEntity::class,
parentColumns = ["departmentId"],
childColumns = ["departmentId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("departmentId"),
Index(value = ["departmentId", "season", "weekStart"], unique = true)
]
)
data class TeacherScheduleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val departmentId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long,
val lastSyncedAt: Long
)
@@ -0,0 +1,36 @@
package agzam.vyatsu.data.remote
/**
* Справочник сокращённых названий факультетов ВятГУ.
* Вынесен из GroupsParser, так как TeachersParser использует те же сокращения.
*/
object FacultyNames {
private val parenthesized = Regex("""\(.*?\)\s?""")
private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
"Факультет лингвистики (ОРУ)" to "ФЛ",
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
"Электротехнический факультет (ОРУ)" to "ЭТФ",
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
)
fun shortname(name: String): String {
val stripped = removeParenthesized(name)
val sh = shorthands[name]
return if (sh != null) "$sh ($stripped)" else stripped
}
fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
}
@@ -14,30 +14,11 @@ 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 в память.
@@ -80,8 +61,7 @@ object GroupsParser {
else -> minOf(divAt, aAt) else -> minOf(divAt, aAt)
} }
if (tagStart < 0) { if (tagStart < 0) {
// хвостовой текст без div-тегов — обычный текст страницы pos = keepPartialTagTail(pending, pos)
pos = pending.length
break break
} }
if (tagStart > pos) { if (tagStart > pos) {
@@ -101,13 +81,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 = shortname(name) if (name.isNotEmpty()) faculty = FacultyNames.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 = removeParenthesized(content), name = FacultyNames.removeParenthesized(content),
faculty = faculty faculty = faculty
) )
} }
@@ -153,13 +133,22 @@ object GroupsParser {
return -1 return -1
} }
private fun shortname(name: String): String { /**
val stripped = removeParenthesized(name) * Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
val sh = shorthands[name] * Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
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(
@@ -0,0 +1,160 @@
package agzam.vyatsu.data.remote
import agzam.vyatsu.data.remote.pdf.TextPatterns
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.TimeSlot
import android.util.Log
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
object TeacherScheduleParser {
private const val TAG = "TeacherParserDebug"
private val DAY_REGEX = Regex("""^(понедельник|вторник|среда|четверг|пятница|суббота|воскресенье)\s+(\d{2}\.\d{2}\.\d{2})$""")
private val INTERVAL_REGEX = Regex("""^(\d{2}:\d{2})-(\d{2}:\d{2})$""")
private val GROUP_TOKEN = Regex("""(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}])""")
private val SPLIT_BY_GROUP = Regex("""(?=(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}]))""")
private val DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yy")
data class ParsedTeacherSchedule(val teachers: List<String>, val days: List<Day>)
/**
* Столбец расписания преподавателя: имя и абсолютный индекс ячейки в шапке.
* Индекс нужен для точного выравнивания с рядами данных — в шапке могут
* присутствовать пустые колонки-разделители, которые не должны сдвигать
* соответствие «преподаватель → столбец».
*/
private data class Column(val teacher: String, val headerIndex: Int)
fun parse(html: String): ParsedTeacherSchedule {
val doc = Jsoup.parse(html)
var headerFound = false
var columns: List<Column> = emptyList()
var teachers: List<String> = emptyList()
val days = mutableListOf<Day>()
var currentDate: LocalDate? = null
var currentIntervals: MutableList<TimeSlot>? = null
fun flushDay() {
val date = currentDate ?: return
val intervals = currentIntervals ?: return
if (intervals.isNotEmpty()) days.add(Day(date, intervals.toList()))
}
for (tr in doc.select("tr")) {
val tds = tr.children().filter { it.tagName() == "td" }
if (tds.size < 2) continue
val first = cellText(tds[0])
val second = cellText(tds[1])
if (!headerFound && first == "День" && second == "Интервал") {
columns = tds.mapIndexedNotNull { index, td ->
if (index < 2) return@mapIndexedNotNull null
val name = cellText(td.selectFirst("span") ?: td)
if (name.isEmpty()) null else Column(name, index)
}.distinctBy { it.teacher }
teachers = columns.map { it.teacher }
headerFound = true
continue
}
val dayMatch = DAY_REGEX.matchEntire(first)
val intervalMatch = INTERVAL_REGEX.matchEntire(if (dayMatch != null) second else first)
if (dayMatch != null) {
flushDay()
currentDate = LocalDate.parse(dayMatch.groupValues[2], DATE_FMT)
currentIntervals = mutableListOf()
}
if (intervalMatch != null && currentIntervals != null) {
parseIntervalRow(tds, intervalMatch, dayMatch != null, columns, currentIntervals!!)
}
}
flushDay()
Log.d(TAG, "parse: teachers=$teachers days=${days.size}")
for (day in days) {
Log.d(TAG, " parse ${day.date}: intervals=${day.intervals.size}")
for (slot in day.intervals) {
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
val teachersInSlot = slot.lessons.map { it.teacher }.distinct()
Log.d(
TAG,
" slot ${slot.start}-${slot.end} total=${slot.lessons.size} meltsov=${mel.size} teachers=$teachersInSlot"
)
mel.forEach { l ->
Log.d(TAG, " mel: room=${l.room} group=${l.group} subj=${l.subject} type=${l.type} raw=${l.raw}")
}
}
}
return ParsedTeacherSchedule(teachers, days)
}
private fun parseIntervalRow(
tds: List<Element>,
intervalMatch: MatchResult,
dayRow: Boolean,
columns: List<Column>,
intervals: MutableList<TimeSlot>
) {
val start = LocalTime.parse(intervalMatch.groupValues[1])
val end = LocalTime.parse(intervalMatch.groupValues[2])
val lessons = mutableListOf<Lesson>()
for (column in columns) {
// Ряд с днём содержит обе ведущие ячейки (День, Интервал) и совпадает
// по индексам с шапкой; ряд только с интервалом пропускает ячейку дня
// (rowspan) — его ячейки сдвинуты на одну позицию влево.
val dataIndex = if (dayRow) column.headerIndex else column.headerIndex - 1
val cell = tds.getOrNull(dataIndex) ?: continue
lessons += parseCell(cell, column.teacher)
}
if (lessons.isNotEmpty()) intervals.add(TimeSlot(start, end, lessons))
}
private fun parseCell(cell: Element, teacher: String): List<Lesson> {
val raw = cellText(cell)
if (raw.isEmpty()) return emptyList()
val room = TextPatterns.ROOM_REGEX.find(raw)?.value
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.find(raw)
val type = LessonType.fromRaw(typeMatch?.value?.trim() ?: "")
val segments = raw.split(SPLIT_BY_GROUP).map { it.trim() }.filter { it.isNotEmpty() }
val groupSegments = segments.filter { GROUP_TOKEN.containsMatchIn(it) }
if (groupSegments.isEmpty()) {
return listOf(
Lesson(
raw = raw,
room = room,
subject = subjectOf(raw, room, typeMatch),
type = type,
teacher = teacher
)
)
}
val subject = subjectOf(raw, room, typeMatch)
return groupSegments.flatMap { segment ->
val groups = GROUP_TOKEN.findAll(segment).map { it.value }.distinct().toList()
val subgroup = TextPatterns.SUBGROUP_REGEX.find(segment)?.value
groups.map { group ->
Lesson(
raw = segment,
room = room,
group = group,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
}
}
}
private fun subjectOf(raw: String, room: String?, typeMatch: MatchResult?): String? {
val end = typeMatch?.range?.first ?: GROUP_TOKEN.find(raw)?.range?.first ?: raw.length
val prefix = raw.substring(0, end).trim()
val stripped = room?.let { prefix.removePrefix(it).trim() } ?: prefix
return stripped.ifEmpty { null }
}
private fun cellText(el: Element): String = el.text().replace('\u00A0', ' ').trim()
}
@@ -0,0 +1,148 @@
package agzam.vyatsu.data.remote
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Department
import agzam.vyatsu.domain.model.ParsedDepartments
import agzam.vyatsu.domain.model.Season
import java.io.InputStream
/**
* Стриминговый парсер страницы преподавателей: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
* Страница teacher.html весит ~2.4MB, поэтому весь документ грузить нельзя.
*/
object TeachersParser {
private val fakNameTag = Regex("""<div[^>]*class="fak_name"""")
private val fakIdInTag = Regex("""data-fak_id="(\d+)"""")
private val kafPeriodTag = Regex("""<div[^>]*class="kafPeriod"""")
private val kafIdInTag = Regex("""data-kaf_period_id="(\d+)"""")
private val prepodLink = Regex("""href="/reports/schedule/prepod/(\d+)_([12])_(\d{8})_(\d{8})\.html"""")
private val tagStrip = Regex("""<[^>]*>""")
private const val CHUNK_SIZE = 8192
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
fun parseStream(input: InputStream): ParsedDepartments {
val reader = input.bufferedReader(Charsets.UTF_8)
val buf = CharArray(CHUNK_SIZE)
val pending = StringBuilder(CHUNK_SIZE * 2)
val departments = ArrayList<Department>()
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
var faculty = ""
fun addRanges(text: String) {
for (m in prepodLink.findAll(text)) {
val departmentId = m.groupValues[1]
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
val range = DateRange(m.groupValues[3], m.groupValues[4])
ranges.getOrPut(departmentId) {
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
}.getOrPut(season) { ArrayList() }.add(range)
}
}
while (true) {
val n = reader.read(buf)
if (n < 0) break
pending.append(buf, 0, n)
var pos = 0
while (pos < pending.length) {
val divAt = pending.indexOf("<div", pos)
val aAt = pending.indexOf("<a", pos)
val tagStart = when {
divAt < 0 -> aAt
aAt < 0 -> divAt
else -> minOf(divAt, aAt)
}
if (tagStart < 0) {
pos = keepPartialTagTail(pending, pos)
break
}
if (tagStart > pos) {
addRanges(pending.substring(pos, tagStart))
}
val tagEnd = findTagEnd(pending, tagStart)
if (tagEnd < 0) break
val tagText = pending.substring(tagStart, tagEnd + 1)
val isDiv = tagText.startsWith("<div")
val isFakName = isDiv && fakNameTag.containsMatchIn(tagText)
val isKafPeriod = isDiv && kafPeriodTag.containsMatchIn(tagText)
if (isFakName || isKafPeriod) {
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
if (closeDiv < 0) break
addRanges(tagText)
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val stripped = tagStrip.replace(content, "").trim()
if (stripped.isNotEmpty()) faculty = FacultyNames.shortname(stripped)
} else {
val m = kafIdInTag.find(tagText)
if (m != null) {
departments += Department(
departmentId = m.groupValues[1].dropLast(1),
name = tagStrip.replace(content, "").trim(),
faculty = faculty
)
}
}
pos = closeDiv + 6
} else {
addRanges(tagText)
pos = tagEnd + 1
}
if (pos > MAX_SLICE) {
pending.delete(0, pos)
pos = 0
}
}
if (pos > 0 && pos <= pending.length) {
pending.delete(0, pos)
}
if (pending.length > MAX_PENDING) {
pending.delete(0, pending.length - MAX_SLICE)
}
}
return ParsedDepartments(departments, ranges)
}
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
private fun findTagEnd(s: CharSequence, start: Int): Int {
var i = start
var quote = 0.toChar()
while (i < s.length) {
val c = s[i]
if (quote != 0.toChar()) {
if (c == quote) quote = 0.toChar()
} else {
when (c) {
'"', '\'' -> quote = c
'>' -> return i
}
}
i++
}
return -1
}
/**
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
*/
private fun keepPartialTagTail(pending: CharSequence, from: Int): Int {
var i = from
while (i < pending.length) {
val c = pending[i]
if (c == '<') {
val tail = pending.substring(i, pending.length)
if (tail == "<" || tail == "<d" || tail == "<di" || tail == "<a") return i
}
i++
}
return pending.length
}
}
@@ -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 ->
Log.d(TAG, "GET $url -> ${resp.code}") if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" } 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 ->
Log.d(TAG, "GET $url -> ${resp.code}") if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" } 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()
Log.d(TAG, "GET $url -> ${resp.code}") if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" } check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
resp.body!!.byteStream() resp.body!!.byteStream()
} }
@@ -10,4 +10,9 @@ object VyatsuUrls {
fun groupPdfUrl(groupId: String, season: Season, range: DateRange): String = "$PDF_BASEURL/${groupId}_${season.apiKey}_${range.first}_${range.second}.pdf" 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,10 +26,6 @@ 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 {
@@ -145,7 +141,7 @@ object PdfScheduleParser {
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы // Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
var found = 0 var found = 0
var currentStart = 0 var currentStart = 0
for (roomMatch in ROOM_REGEX.findAll(content)) { for (roomMatch in TextPatterns.ROOM_REGEX.findAll(content)) {
found++ found++
val raw = content.substring(currentStart, roomMatch.range.last + 1) val raw = content.substring(currentStart, roomMatch.range.last + 1)
@@ -155,7 +151,7 @@ object PdfScheduleParser {
val room = roomMatch.value val room = roomMatch.value
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList() val typeMatch = TextPatterns.LESSON_TYPE_REGEX.findAll(raw).toList()
if(typeMatch.isEmpty()) { if(typeMatch.isEmpty()) {
lessons.add(Lesson( lessons.add(Lesson(
raw=raw, raw=raw,
@@ -170,7 +166,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 = SUBGROUP_REGEX.find(subject) val subgroupMatch = TextPatterns.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()
@@ -0,0 +1,12 @@
package agzam.vyatsu.data.remote.pdf
import agzam.vyatsu.domain.model.LessonType
object TextPatterns {
val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" })
val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
}
@@ -14,6 +14,16 @@ 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())
@@ -0,0 +1,86 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DepartmentEntity
import agzam.vyatsu.data.local.DepartmentPeriodEntity
import agzam.vyatsu.data.local.TeacherDao
import agzam.vyatsu.data.remote.TeachersParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Department
import agzam.vyatsu.domain.repository.TeacherRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class TeacherRepositoryImpl(
private val teacherDao: TeacherDao,
) : TeacherRepository {
override fun observeDepartments(): Flow<List<Department>> =
teacherDao.observeDepartments().map { list -> list.map { it.toDomain() } }
override suspend fun getDepartments(): List<Department> =
teacherDao.observeDepartments().first().map { it.toDomain() }
override suspend fun getFaculties(): List<String> = teacherDao.getFaculties()
override suspend fun refreshIfStale(): Boolean {
val last = teacherDao.maxSyncedAt()
if (last != null && now() - last < STALE_WINDOW_MS) {
return true
}
refresh()
return true
}
override suspend fun forceRefresh(): Boolean {
refresh()
return true
}
private suspend fun refresh() {
val parsed = withContext(Dispatchers.IO) {
VyatsuHttpClient.openStream(VyatsuUrls.TEACHER_PAGE).use(TeachersParser::parseStream)
}
val now = now()
teacherDao.insertAll(
parsed.departments.map { department ->
DepartmentEntity(
departmentId = department.departmentId,
name = department.name,
faculty = department.faculty,
lastSyncedAt = now
)
}
)
val periods = parsed.departments.flatMap { department ->
parsed.dateRanges[department.departmentId].orEmpty().flatMap { (season, ranges) ->
ranges.map { range ->
DepartmentPeriodEntity(
departmentId = department.departmentId,
season = season,
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
)
}
}
}
teacherDao.deleteAllPeriods()
teacherDao.insertPeriods(periods)
}
private fun now(): Long = System.currentTimeMillis()
private fun DepartmentEntity.toDomain(): Department = Department(
departmentId = departmentId,
name = name,
faculty = faculty
)
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
}
}
@@ -0,0 +1,321 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DepartmentPeriodEntity
import agzam.vyatsu.data.local.TeacherDao
import agzam.vyatsu.data.local.TeacherDayEntity
import agzam.vyatsu.data.local.TeacherLessonEntity
import agzam.vyatsu.data.local.TeacherLessonWithDepartment
import agzam.vyatsu.data.local.TeacherScheduleDao
import agzam.vyatsu.data.local.TeacherScheduleEntity
import agzam.vyatsu.data.remote.TeacherScheduleParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.model.BulkSyncProgress
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Teacher
import agzam.vyatsu.domain.model.TeacherSchedule
import agzam.vyatsu.domain.model.TimeSlot
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalTime
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
class TeacherScheduleRepositoryImpl(
private val scheduleDao: TeacherScheduleDao,
private val teacherDao: TeacherDao
) : TeacherScheduleRepository {
private val syncMutex = Mutex()
override fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> =
combine(
scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()),
scheduleDao.observeLessonsForPeriod(period.season.name, period.weekStart.toEpochDay())
) { days, lessons ->
buildTeacherSchedule(period, days, lessons)
}
override fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>> =
scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay())
.map { names -> names.map { name -> Teacher(name = name) } }
override suspend fun syncPeriod(date: LocalDate, force: Boolean): Flow<BulkSyncProgress> = flow {
// Unique index (departmentId, season, weekStart) breaks on interleaved
// delete+insert from concurrent syncPeriod() callers.
syncMutex.withLock {
val targets = withContext(Dispatchers.IO) {
teacherDao.getPeriodsForDate(date.toEpochDay())
}
if (targets.isEmpty()) {
emit(BulkSyncProgress(0, 0, 0))
return@withLock
}
val now = now()
var skippedCount = 0
val nonSkipped = mutableListOf<DepartmentPeriodEntity>()
for (target in targets) {
val last = withContext(Dispatchers.IO) {
scheduleDao.lastSyncedAt(target.departmentId, target.season.name, target.weekStart)
}
val shouldSkip = !force && last != null && now - last < STALE_WINDOW_MS
if (shouldSkip) {
skippedCount++
} else {
nonSkipped.add(target)
}
}
emit(BulkSyncProgress(total = targets.size, done = skippedCount, failed = 0))
val total = targets.size
val semaphore = Semaphore(PARALLELISM)
val jobs = coroutineScope {
nonSkipped.map { target ->
async {
semaphore.withPermit {
try {
withContext(Dispatchers.IO) { syncDepartment(target) }
true
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Log.e(
TAG,
"sync: failed for department ${target.departmentId} " +
"season ${target.season} week ${target.weekStart} " +
"(${e::class.simpleName}: ${e.message})",
e
)
false
}
}
}
}
}
var done = skippedCount
var failed = 0
for (job in jobs) {
if (job.await()) done++ else failed++
emit(BulkSyncProgress(total, done + failed, failed))
}
withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) }
}
}
override suspend fun hasData(period: SchedulePeriod): Boolean =
withContext(Dispatchers.IO) {
scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay())
}
override suspend fun weeksFor(date: LocalDate): List<SchedulePeriod> =
withContext(Dispatchers.IO) {
teacherDao.getPeriodsForDate(date.toEpochDay())
.distinctBy { it.season to (it.weekStart to it.weekEnd) }
.flatMap { window ->
val windowStart = LocalDate.ofEpochDay(window.weekStart)
val windowEnd = LocalDate.ofEpochDay(window.weekEnd)
generateSequence(
windowStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
) { it.plusWeeks(1) }
.takeWhile { it <= windowEnd }
.map { monday ->
val weekEnd = monday.plusDays(6)
SchedulePeriod(
weekStart = monday,
weekEnd = if (weekEnd.isAfter(windowEnd)) windowEnd else weekEnd,
season = window.season
)
}
}
.distinctBy { it.season to it.weekStart }
.sortedBy { it.weekStart }
}
override suspend fun clearAll() = withContext(Dispatchers.IO) {
scheduleDao.deleteAll()
}
private suspend fun syncDepartment(target: DepartmentPeriodEntity) {
val season = target.season
val range = DateRange(
LocalDate.ofEpochDay(target.weekStart).format(RANGE_FMT),
LocalDate.ofEpochDay(target.weekEnd).format(RANGE_FMT)
)
val url = VyatsuUrls.teacherPrepodUrl(target.departmentId, season, range)
Log.d(TAG, "syncDepartment: dept=${target.departmentId} url=$url")
val html = VyatsuHttpClient.getBytes(url).toString(Charsets.UTF_8)
val parsed = TeacherScheduleParser.parse(html)
Log.d(TAG, "syncDepartment: dept=${target.departmentId} parsedDays=${parsed.days.size}")
parsed.days.forEach { day ->
day.intervals.forEach { slot ->
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
if (mel.isNotEmpty()) {
Log.d(TAG, "syncDepartment: dept=${target.departmentId} date=${day.date} slot=${slot.start}-${slot.end} melLessons=${mel.size}")
}
}
}
val dayEntities = parsed.days.map {
TeacherDayEntity(scheduleId = 0, date = it.date.toEpochDay())
}
val lessonEntities = parsed.days.flatMap { day ->
day.intervals.flatMapIndexed { slotIndex, slot ->
slot.lessons.mapIndexedNotNull { lessonIndex, lesson ->
val teacher = lesson.teacher ?: return@mapIndexedNotNull null
TeacherLessonEntity(
dayId = 0,
date = day.date.toEpochDay(),
slotIndex = slotIndex,
start = slot.start.toMinutesOfDay(),
end = slot.end.toMinutesOfDay(),
lessonIndex = lessonIndex,
raw = lesson.raw,
room = lesson.room,
groupName = lesson.group,
subgroup = lesson.subgroup,
subject = lesson.subject,
type = lesson.type,
teacher = teacher
)
}
}
}
Log.d(TAG, "syncDepartment: dept=${target.departmentId} -> lessons=${lessonEntities.size}")
val melLessonEntities = lessonEntities.filter { it.teacher == "Мельцов В.Ю." }
melLessonEntities.forEach { le ->
Log.d(
TAG,
"syncDepartment: dept=${target.departmentId} MEL date=${le.date} slotIndex=${le.slotIndex} " +
"start=$le.start end=$le.end raw=${le.raw}"
)
}
scheduleDao.replaceSchedule(
TeacherScheduleEntity(
departmentId = target.departmentId,
season = season,
weekStart = target.weekStart,
weekEnd = target.weekEnd,
lastSyncedAt = now()
),
dayEntities,
lessonEntities
)
}
private suspend fun trimToRecent(keepDistinctPeriods: Int) {
val schedules = scheduleDao.getSchedules()
schedules
.map { it.season to it.weekStart }
.distinct()
.sortedByDescending { it.second }
.drop(keepDistinctPeriods)
.forEach { (season, weekStart) ->
scheduleDao.deletePeriod(season.name, weekStart)
}
}
private fun buildTeacherSchedule(
period: SchedulePeriod,
dayEntities: List<TeacherDayEntity>,
lessons: List<TeacherLessonWithDepartment>
): TeacherSchedule? {
val weekDays = dayEntities
.filter {
it.date >= period.weekStart.toEpochDay() && it.date <= period.weekEnd.toEpochDay()
}
if (weekDays.isEmpty()) return null
val lessonsByDate = lessons.groupBy { it.lesson.date }
val days = weekDays
.sortedBy { it.date }
.distinctBy { it.date }
.map { day ->
val dayLessons = lessonsByDate[day.date].orEmpty()
Log.d(
TAG,
"build: date=${day.date} totalLessons=${dayLessons.size} " +
"groupedByStart=${dayLessons.groupBy { it.lesson.start }.keys.sorted()}"
)
val slots = dayLessons.groupBy { it.lesson.start }
.toSortedMap()
.map { (startMin, slotLessons) ->
val first = slotLessons.first().lesson
val times = slotLessons.map { "${it.lesson.start}-${it.lesson.end}" }.distinct()
val teachers = slotLessons.map { it.lesson.teacher }.distinct()
Log.d(
TAG,
"build: date=${day.date} start=$startMin firstEnd=$first.end " +
"times=$times teachers=$teachers"
)
TimeSlot(
start = LocalTime.of(startMin / 60, startMin % 60),
end = LocalTime.of(first.end / 60, first.end % 60),
lessons = slotLessons
.sortedBy { it.lesson.lessonIndex }
.map { it.toDomain() }
)
}
Day(date = LocalDate.ofEpochDay(day.date), intervals = slots)
}
return TeacherSchedule(period = period, days = days)
}
private fun TeacherLessonWithDepartment.toDomain(): Lesson = Lesson(
raw = lesson.raw,
room = lesson.room,
group = lesson.groupName,
subgroup = lesson.subgroup,
subject = lesson.subject,
type = lesson.type,
teacher = lesson.teacher,
department = departmentLabel(departmentId, departmentName)
)
// Названия, не выводимые акронимом из слов (например «топ-уровня в сфере…»)
private val DEPARTMENT_LABELS = mapOf(
"974" to "Топ ИИ"
)
private fun departmentLabel(departmentId: String, departmentName: String?): String {
DEPARTMENT_LABELS[departmentId]?.let { return it }
val name = departmentName ?: return "каф. $departmentId"
val words = name.removePrefix("Кафедра ").removePrefix("кафедра ")
.substringBefore(" (")
.split(" ")
.filter { it.isNotBlank() && it != "и" }
val acronym = words.joinToString("") { it.first().uppercase().toString() }
return acronym.ifBlank { "каф. $departmentId" }
}
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun now(): Long = System.currentTimeMillis()
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
private const val PARALLELISM = 2
private const val TAG = "TeacherScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
}
}
@@ -6,9 +6,13 @@ 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
@@ -19,6 +23,7 @@ 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()
@@ -39,6 +44,17 @@ 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)
} }
@@ -0,0 +1,7 @@
package agzam.vyatsu.domain.model
data class BulkSyncProgress(
val total: Int,
val done: Int,
val failed: Int
)
@@ -0,0 +1,11 @@
package agzam.vyatsu.domain.model
/**
* Кафедра ВятГУ.
* departmentId — идентификатор кафедры из data-kaf_period_id без последней цифры (как в URL).
*/
data class Department(
val departmentId: String,
val name: String,
val faculty: String
)
@@ -26,4 +26,6 @@ data class Lesson(
/** Преподаватель **/ /** Преподаватель **/
val teacher: String? = null, val teacher: String? = null,
/** Кафедра-источник (только в расписании преподавателей) **/
val department: String? = null,
) )
@@ -0,0 +1,6 @@
package agzam.vyatsu.domain.model
data class ParsedDepartments(
val departments: List<Department>,
val dateRanges: Map<String, Map<Season, List<DateRange>>>
)
@@ -0,0 +1,11 @@
package agzam.vyatsu.domain.model
/**
* Преподаватель ВятГУ.
* Один и тот же преподаватель может вести занятия в нескольких кафедрах,
* поэтому модель не привязана к departmentId — расписание агрегируется
* по всем кафедрам периода.
*/
data class Teacher(
val name: String
)
@@ -0,0 +1,57 @@
package agzam.vyatsu.domain.model
import java.time.LocalDate
import java.time.LocalTime
/**
* Расписание преподавателя за период, агрегированное по всем кафедрам.
*/
data class TeacherSchedule(
val period: SchedulePeriod,
val days: List<Day>
) {
/**
* Возвращает дни периода только с занятиями указанного преподавателя.
* Слоты без занятий отбрасываются, порядок [Day] и [TimeSlot] сохраняется.
*
* Дедупликация — ТОЛЬКО по полному совпадению (дата + слот + аудитория +
* группа + предмет + тип + raw): два занятия коллапсируют в одно лишь когда
* они идентичны целиком (одно физическое занятие, прочитанное из файлов двух
* кафедр). Всё, что отличается хотя бы одним полем, остаётся и показывается.
*/
fun daysForTeacher(teacherName: String): List<Day> =
days.mapNotNull { day ->
val seen = HashSet<FullMatchKey>()
val intervals = day.intervals.mapNotNull { slot ->
val lessons = slot.lessons
.filter { it.teacher == teacherName }
.filter {
seen.add(
FullMatchKey(
date = day.date,
start = slot.start,
end = slot.end,
room = it.room,
group = it.group,
subject = it.subject,
type = it.type,
raw = it.raw
)
)
}
if (lessons.isEmpty()) null else slot.copy(lessons = lessons)
}
if (intervals.isEmpty()) null else day.copy(intervals = intervals)
}
private data class FullMatchKey(
val date: LocalDate,
val start: LocalTime,
val end: LocalTime,
val room: String?,
val group: String?,
val subject: String?,
val type: LessonType,
val raw: String
)
}
@@ -9,6 +9,14 @@ 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)
@@ -0,0 +1,12 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.Department
import kotlinx.coroutines.flow.Flow
interface TeacherRepository {
fun observeDepartments(): Flow<List<Department>>
suspend fun getDepartments(): List<Department>
suspend fun getFaculties(): List<String>
suspend fun refreshIfStale(): Boolean
suspend fun forceRefresh(): Boolean
}
@@ -0,0 +1,17 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.BulkSyncProgress
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Teacher
import agzam.vyatsu.domain.model.TeacherSchedule
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
interface TeacherScheduleRepository {
fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>>
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
suspend fun syncPeriod(date: LocalDate, force: Boolean = false): Flow<BulkSyncProgress>
suspend fun hasData(period: SchedulePeriod): Boolean
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
suspend fun clearAll()
}
@@ -1,152 +0,0 @@
package agzam.vyatsu.ui.groups
import agzam.vyatsu.domain.model.Group
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Страница выбора группы
* @author Agzam
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupPickerScreen(viewModel: GroupPickerViewModel, onGroupPicked: (Group) -> Unit) {
val state by viewModel.uiState.collectAsState()
var isSearchActive by remember { mutableStateOf(false) }
Scaffold { innerPadding ->
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(innerPadding)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
if (!isSearchActive) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Выберите группу",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
}
}
if (state.isLoading && state.groups.isEmpty()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
return@Column
}
if (state.error != null && state.groups.isEmpty()) {
Column(Modifier.fillMaxWidth()) {
Text(state.error.orEmpty(), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
TextButton(onClick = { viewModel.retry() }) { Text("Повторить") }
}
return@Column
}
SearchBar(
modifier = Modifier.fillMaxWidth(),
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
inputField = {
SearchBarDefaults.InputField(
query = state.searchQuery,
onQueryChange = { viewModel.search(it) },
onSearch = { isSearchActive = false },
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
placeholder = { Text("Поиск группы") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (isSearchActive && state.searchQuery.isNotEmpty()) {
IconButton(onClick = { viewModel.search("") }) {
Icon(Icons.Default.Clear, contentDescription = "Очистить")
}
}
}
)
}
) {
GroupList(
groups = state.filteredGroups,
onPick = { group ->
isSearchActive = false
onGroupPicked(group)
}
)
}
if (!isSearchActive) {
Spacer(modifier = Modifier.height(8.dp))
GroupList(state.filteredGroups, onGroupPicked)
}
}
}
}
@Composable
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(groups) { group ->
Column(modifier = Modifier.fillMaxWidth() .clickable { onPick(group) }) {
Box(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
Text(group.name, style = MaterialTheme.typography.bodyMedium)
// XXX: стоит ли добавлять факультет или это засорит интерфейс
// Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom) {
// if (group.faculty.isNotEmpty()) Text(text = group.faculty + " · ", color = MaterialTheme.colorScheme.onSurfaceVariant)
// }
}
}
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@@ -1,100 +0,0 @@
package agzam.vyatsu.ui.groups
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.repository.GroupRepository
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* @author Agzam
*/
class GroupPickerViewModel(private val repository: GroupRepository) : ViewModel() {
private val _uiState = MutableStateFlow(GroupPickerUiState())
val uiState: StateFlow<GroupPickerUiState> = _uiState.asStateFlow()
init {
load()
}
private fun load() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val ok = try {
repository.refreshIfStale()
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.message ?: "Не удалось загрузить группы"
)
false
}
if (!ok && _uiState.value.groups.isEmpty()) {
_uiState.value = _uiState.value.copy(
error = _uiState.value.error ?: "Не удалось обновить список групп"
)
}
}
repository.observeGroups().collect { groups ->
val query = _uiState.value.searchQuery
_uiState.value = _uiState.value.copy(
isLoading = false,
groups = groups,
filteredGroups = filterGroups(groups, query),
error = null
)
}
}
}
fun search(query: String) {
val groups = _uiState.value.groups
_uiState.value = _uiState.value.copy(
searchQuery = query,
filteredGroups = filterGroups(groups, query)
)
}
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
if (query.isBlank()) return groups
val lowerQuery = query.lowercase()
return groups.filter { group ->
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
}
}
fun retry() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
load()
}
fun refresh() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
repository.forceRefresh()
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message ?: "Не удалось обновить список групп"
)
return@withContext
}
}
}
}
}
data class GroupPickerUiState(
val isLoading: Boolean = true,
val error: String? = null,
val groups: List<Group> = emptyList(),
val searchQuery: String = "",
val filteredGroups: List<Group> = emptyList()
)
@@ -6,6 +6,8 @@ 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
@@ -19,6 +21,7 @@ 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
@@ -35,10 +38,13 @@ 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 = {
@@ -70,7 +76,14 @@ fun HomeScreen(
.padding(innerPadding) .padding(innerPadding)
) { ) {
when (tab) { when (tab) {
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup) HomeTab.SCHEDULE -> if (teacherState.teacherName != null) {
TeacherScheduleScreen(
viewModel = teacherScheduleViewModel,
onSwitchTeacher = onSwitchTeacher
)
} else {
ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
}
HomeTab.CALLS -> CallsScreen() HomeTab.CALLS -> CallsScreen()
HomeTab.BUILDINGS -> BuildingsScreen() HomeTab.BUILDINGS -> BuildingsScreen()
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel) HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
@@ -0,0 +1,336 @@
package agzam.vyatsu.ui.picker
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Teacher
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PickerScreen(
viewModel: PickerViewModel,
onGroupPicked: (Group) -> Unit,
onTeacherPicked: (Teacher) -> Unit
) {
val state by viewModel.uiState.collectAsState()
var segment by rememberSaveable { mutableStateOf(PickerSegment.GROUPS) }
LaunchedEffect(segment) { viewModel.selectSegment(segment) }
Scaffold { innerPadding ->
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(innerPadding)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(
selected = segment == PickerSegment.GROUPS,
onClick = { segment = PickerSegment.GROUPS },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
) {
Text("Группы")
}
SegmentedButton(
selected = segment == PickerSegment.TEACHERS,
onClick = { segment = PickerSegment.TEACHERS },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
) {
Text("Преподаватели")
}
}
Spacer(Modifier.height(16.dp))
when (segment) {
PickerSegment.GROUPS -> GroupSection(
state = state,
onSearch = viewModel::searchGroup,
onRetry = viewModel::retryGroups,
onRefresh = viewModel::refreshGroups,
onGroupPicked = onGroupPicked
)
PickerSegment.TEACHERS -> TeacherSection(
state = state,
onSearch = viewModel::searchTeacher,
onRetry = viewModel::retryTeachers,
onTeacherPicked = onTeacherPicked
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GroupSection(
state: PickerUiState,
onSearch: (String) -> Unit,
onRetry: () -> Unit,
onRefresh: () -> Unit,
onGroupPicked: (Group) -> Unit
) {
var isSearchActive by remember { mutableStateOf(false) }
if (!isSearchActive) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Выберите группу",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
}
}
if (state.groupsLoading && state.groups.isEmpty()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
return
}
if (state.error != null && state.groups.isEmpty()) {
Column(Modifier.fillMaxWidth()) {
Text(
state.error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = onRetry) { Text("Повторить") }
}
return
}
SearchBar(
modifier = Modifier.fillMaxWidth(),
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
inputField = {
SearchBarDefaults.InputField(
query = state.groupQuery,
onQueryChange = onSearch,
onSearch = { isSearchActive = false },
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
placeholder = { Text("Поиск группы") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (isSearchActive && state.groupQuery.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Clear, contentDescription = "Очистить")
}
}
}
)
}
) {
GroupList(
groups = state.filteredGroups,
onPick = { group ->
isSearchActive = false
onGroupPicked(group)
}
)
}
if (!isSearchActive) {
Spacer(modifier = Modifier.height(8.dp))
GroupList(state.filteredGroups, onGroupPicked)
}
}
@Composable
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(groups) { group ->
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(group) }) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
Text(group.name, style = MaterialTheme.typography.bodyMedium)
}
}
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TeacherSection(
state: PickerUiState,
onSearch: (String) -> Unit,
onRetry: () -> Unit,
onTeacherPicked: (Teacher) -> Unit
) {
var isSearchActive by remember { mutableStateOf(false) }
val progress = state.syncProgress
val syncing = progress != null && progress.done < progress.total
if (!isSearchActive) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Выберите преподавателя",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onRetry) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
}
}
if (syncing) {
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
LinearProgressIndicator(
progress = { progress.done / progress.total.toFloat() },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(4.dp))
Text(
text = "Кафедры: ${progress.done}/${progress.total}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (state.teachersLoading && state.teachers.isEmpty() && !syncing) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
return
}
if (state.error != null && state.teachers.isEmpty() && !syncing) {
Column(Modifier.fillMaxWidth()) {
Text(
state.error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = onRetry) { Text("Повторить") }
}
return
}
SearchBar(
modifier = Modifier.fillMaxWidth(),
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
inputField = {
SearchBarDefaults.InputField(
query = state.teacherQuery,
onQueryChange = onSearch,
onSearch = { isSearchActive = false },
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
placeholder = { Text("Поиск преподавателя") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (isSearchActive && state.teacherQuery.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Clear, contentDescription = "Очистить")
}
}
}
)
}
) {
TeacherList(
teachers = state.filteredTeachers,
onPick = { teacher ->
isSearchActive = false
onTeacherPicked(teacher)
}
)
}
if (!isSearchActive) {
Spacer(modifier = Modifier.height(8.dp))
when {
state.teachers.isEmpty() -> Text(
text = "Кафедры без расписания на эту неделю",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
else -> TeacherList(state.filteredTeachers, onTeacherPicked)
}
}
}
@Composable
private fun TeacherList(teachers: List<Teacher>, onPick: (Teacher) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(teachers) { teacher ->
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(teacher) }) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
Text(teacher.name, style = MaterialTheme.typography.bodyMedium)
}
}
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@@ -0,0 +1,3 @@
package agzam.vyatsu.ui.picker
enum class PickerSegment { GROUPS, TEACHERS }
@@ -0,0 +1,204 @@
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,13 +1,11 @@
package agzam.vyatsu.ui.schedule package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day import agzam.vyatsu.ui.schedule.components.DayChips
import agzam.vyatsu.domain.model.LessonType import agzam.vyatsu.ui.schedule.components.FULL_DATE
import agzam.vyatsu.domain.model.SchedulePeriod import agzam.vyatsu.ui.schedule.components.LessonSlot
import agzam.vyatsu.domain.model.TimeSlot import agzam.vyatsu.ui.schedule.components.WeekSelector
import android.widget.Toast import agzam.vyatsu.ui.schedule.components.dayName
import androidx.compose.foundation.background import agzam.vyatsu.ui.schedule.components.hPadding
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
@@ -16,39 +14,19 @@ 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.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListLayoutInfo
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.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
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
@@ -57,27 +35,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.collectLatest
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
val hPadding = 16.dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -226,290 +187,3 @@ 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)
}
)
}
}
}
}
private fun getVisiblePercentage(item: LazyListItemInfo, layoutInfo: LazyListLayoutInfo): Float {
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
val itemStart = item.offset
val itemEnd = item.offset + item.size
val visibleStart = max(itemStart, viewportStart)
val visibleEnd = min(itemEnd, viewportEnd)
val visibleSize = visibleEnd - visibleStart
return if (visibleSize <= 0) 0f else visibleSize.toFloat() / item.size
}
@Composable
private 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)
)
}
}
}
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")
@@ -0,0 +1,133 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.Day
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.collectLatest
import java.time.LocalDate
import kotlin.math.max
import kotlin.math.min
internal fun getVisiblePercentage(item: LazyListItemInfo, layoutInfo: LazyListLayoutInfo): Float {
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
val itemStart = item.offset
val itemEnd = item.offset + item.size
val visibleStart = max(itemStart, viewportStart)
val visibleEnd = min(itemEnd, viewportEnd)
val visibleSize = visibleEnd - visibleStart
return if (visibleSize <= 0) 0f else visibleSize.toFloat() / item.size
}
@Composable
internal fun DayChips(
days: List<Day>,
selectedDate: LocalDate?,
todayDate: LocalDate?,
pagerState: PagerState,
onSelect: (LocalDate) -> Unit
) {
val listState = rememberLazyListState()
val density = LocalDensity.current
val paddingPx = with(density) { hPadding.toPx() }
LaunchedEffect(pagerState, days) {
snapshotFlow {
pagerState.currentPage
}.collectLatest { targetPage ->
if (targetPage in days.indices) {
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val targetItem = visibleItems.firstOrNull { it.index == targetPage }
if (targetItem != null) {
val visiblePercentage = getVisiblePercentage(targetItem, layoutInfo)
val minVisibleThreshold = 0.9f
if (visiblePercentage < minVisibleThreshold) {
val viewPortWidth = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val halfViewport = viewPortWidth / 2f
val centerOffset = (halfViewport - (targetItem.size / 2f) - paddingPx).toInt()
listState.animateScrollToItem(index = targetPage, scrollOffset = -centerOffset)
}
} else {
val viewPortWidth = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val halfViewport = viewPortWidth / 2f
val averageSize = visibleItems.map { it.size }.average().toInt().coerceAtLeast(150)
val centerOffset = (halfViewport - (averageSize / 2f) - paddingPx).toInt()
listState.animateScrollToItem(index = targetPage, scrollOffset = -centerOffset)
}
}
}
}
LazyRow(state = listState, horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date
FilterChip(
selected = selected,
onClick = { onSelect(day.date) },
label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
dayName(day.date.dayOfWeek),
style = MaterialTheme.typography.labelMedium
)
Text(
day.date.format(CHIP_DATE),
style = MaterialTheme.typography.labelSmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
},
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayChip && !selected) {
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
)
} else {
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
},
shape = RoundedCornerShape(8.dp)
)
}
}
}
@@ -0,0 +1,128 @@
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)
)
}
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package agzam.vyatsu.ui.schedule.components
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.format.DateTimeFormatter
internal val hPadding = 16.dp
internal fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
DayOfWeek.MONDAY -> "Пн"
DayOfWeek.TUESDAY -> "Вт"
DayOfWeek.WEDNESDAY -> "Ср"
DayOfWeek.THURSDAY -> "Чт"
DayOfWeek.FRIDAY -> "Пт"
DayOfWeek.SATURDAY -> "Сб"
DayOfWeek.SUNDAY -> "Вс"
}
internal val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
internal val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
internal val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
@@ -0,0 +1,96 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.SchedulePeriod
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun WeekSelector(
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.clickable(enabled = true, onClick = { expanded = true })
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
selected?.let(::weekLabel) ?: "Период",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.widthIn(min = 220.dp)
) {
periods.forEach { period ->
val isTodayWeek = todayWeekStart == period.weekStart
DropdownMenuItem(
text = {
Text(
weekLabel(period),
style = MaterialTheme.typography.bodyLarge
)
},
trailingIcon = if (isTodayWeek) {
{
Text(
"сегодня",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} else {
null
},
onClick = {
expanded = false
onSelect(period.weekStart)
}
)
}
}
}
}
internal fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@@ -0,0 +1,188 @@
package agzam.vyatsu.ui.teacher
import agzam.vyatsu.ui.schedule.components.DayChips
import agzam.vyatsu.ui.schedule.components.FULL_DATE
import agzam.vyatsu.ui.schedule.components.LessonSlot
import agzam.vyatsu.ui.schedule.components.WeekSelector
import agzam.vyatsu.ui.schedule.components.dayName
import agzam.vyatsu.ui.schedule.components.hPadding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher: () -> Unit) {
val state by viewModel.uiState.collectAsState()
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(state.teacherName ?: "Расписание преподавателя", style = MaterialTheme.typography.titleMedium) },
actions = {
if (state.isLoading && state.schedule != null) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
TextButton(onClick = { onSwitchTeacher() }) {
Text("Сменить преподавателя")
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
},
windowInsets = WindowInsets(0.dp)
)
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
when {
state.schedule == null && state.loadError -> {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Не удалось загрузить расписание",
style = MaterialTheme.typography.bodyLarge
)
TextButton(onClick = { viewModel.refresh() }) {
Text("Повторить")
}
}
}
state.schedule == null -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
else -> {
val schedule = state.schedule!!
val weekDays = schedule.days
val day = weekDays.firstOrNull { it.date == state.dayDate }
if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
val pagerState = rememberPagerState(
initialPage = selectedDayIndex,
pageCount = { weekDays.size }
)
LaunchedEffect(state.dayDate) {
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
if (targetPage != -1 && targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage)
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (pagerState.isScrollInProgress && page in weekDays.indices) {
viewModel.selectDay(weekDays[page].date)
}
}
}
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
Row(
Modifier.fillMaxWidth().padding(horizontal = hPadding),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
WeekSelector(
periods = state.weeks.ifEmpty { listOf(schedule.period) },
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate
?.let { todayDate ->
state.weeks.firstOrNull { period ->
!todayDate.isBefore(period.weekStart) && !todayDate.isAfter(period.weekEnd)
}?.weekStart
}
?: schedule.period.weekStart.takeIf {
state.todayDate?.let { today -> !today.isBefore(it) && !today.isAfter(it.plusDays(6)) } == true
},
onSelect = viewModel::selectWeek
)
}
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
DayChips(
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
onSelect = viewModel::selectDay,
pagerState = pagerState
)
HorizontalDivider(Modifier.padding(top = 0.dp, start = hPadding, end = hPadding))
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
val pagerDay = weekDays.getOrNull(page)
if (pagerDay == null || pagerDay.intervals.all { it.lessons.isEmpty() }) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(pagerDay.intervals) { slotIndex, slot ->
LessonSlot(slotIndex = slotIndex, slot = slot, showTeacher = false)
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,243 @@
package agzam.vyatsu.ui.teacher
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TeacherSchedule
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class TeacherScheduleViewModel(
private val scheduleRepository: TeacherScheduleRepository,
private val preferencesRepository: PreferencesRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(TeacherScheduleUiState())
val uiState: StateFlow<TeacherScheduleUiState> = _uiState.asStateFlow()
private var currentTeacherName: String? = null
private var currentPeriod: SchedulePeriod? = null
private var scheduleJob: Job? = null
private var weeksJob: Job? = null
private var loadRevision = 0
init {
viewModelScope.launch {
val teacherId = preferencesRepository.selectedTeacherId()
if (teacherId != null) {
selectTeacher(teacherId)
} else {
_uiState.value = _uiState.value.copy(noTeacher = true, teacherName = null)
}
}
}
fun selectTeacher(name: String) {
val revision = ++loadRevision
currentTeacherName = name
_uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false)
viewModelScope.launch {
try {
preferencesRepository.setSelectedTeacherId(name)
val savedWeekStart = runCatching {
preferencesRepository.selectedTeacherWeekStart()?.let(LocalDate::parse)
}.getOrNull()
val savedDayDate = runCatching {
preferencesRepository.selectedTeacherDayDate()?.let(LocalDate::parse)
}.getOrNull()
val today = ScheduleDates.moscowToday()
val weekStart = savedWeekStart
?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(
noTeacher = false,
teacherName = name,
weekStart = weekStart,
dayDate = savedDayDate
)
val period = SchedulePeriod(
weekStart = weekStart,
weekEnd = weekStart.plusDays(6),
season = ScheduleDates.currentSeason(today)
)
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
}
}
private suspend fun loadSchedule(period: SchedulePeriod, revision: Int) {
if (revision != loadRevision) return
scheduleJob?.cancel()
scheduleJob = null
currentPeriod = period
if (!scheduleRepository.hasData(period)) {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
try {
// force = true: hasData() already proved there is nothing cached for
// this period, so never let the staleness window skip the fetch.
scheduleRepository.syncPeriod(period.weekStart, force = true).collect { }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
}
if (revision != loadRevision) return
if (!scheduleRepository.hasData(period)) {
_uiState.value = _uiState.value.copy(isLoading = false, loadError = true)
return
}
}
weeksJob?.cancel()
weeksJob = viewModelScope.launch {
val weeks = scheduleRepository.weeksFor(period.weekStart)
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(weeks = weeks)
}
scheduleJob = viewModelScope.launch {
scheduleRepository.observeSchedule(period).collect { schedule ->
if (revision != loadRevision) {
throw CancellationException("stale schedule observation")
}
currentTeacherName?.let { applySchedule(schedule, it) }
}
}
}
private suspend fun applySchedule(schedule: TeacherSchedule?, teacherName: String) {
val current = _uiState.value
if (schedule == null) return
val filteredDays = schedule.daysForTeacher(teacherName)
android.util.Log.d("TeacherSchedDebug", "apply: teacher=$teacherName periodWeek=$schedule.period rawDays=${schedule.days.size} filteredDays=${filteredDays.size}")
filteredDays.forEach { d ->
android.util.Log.d(
"TeacherSchedDebug",
"apply: filteredDay=${d.date} slots=${d.intervals.map { "${it.start}-${it.end}:${it.lessons.size}" }}"
)
}
val today = ScheduleDates.moscowToday()
val todayDate = if (isInPeriod(today, schedule.period)) today else null
val dayDate = when {
filteredDays.any { it.date == current.dayDate } -> current.dayDate
filteredDays.any { it.date == todayDate } -> todayDate
else -> filteredDays.firstOrNull()?.date
}
preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedTeacherDayDate(it.toString()) }
_uiState.value = current.copy(
schedule = schedule.copy(days = filteredDays),
weekStart = schedule.period.weekStart,
dayDate = dayDate,
todayDate = todayDate,
isLoading = false,
loadError = false
)
}
fun selectWeek(weekStart: LocalDate) {
val state = _uiState.value
val name = state.teacherName ?: return
if (state.weekStart == weekStart) return
val revision = ++loadRevision
viewModelScope.launch {
try {
preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString())
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(
weekStart = weekStart,
dayDate = null,
schedule = null,
isLoading = true,
loadError = false
)
val period = SchedulePeriod(
weekStart = weekStart,
weekEnd = weekStart.plusDays(6),
season = ScheduleDates.currentSeason(weekStart)
)
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
}
}
fun selectDay(date: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
if (date.isBefore(schedule.period.weekStart) || date.isAfter(schedule.period.weekEnd)) return
viewModelScope.launch {
preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString())
preferencesRepository.setSelectedTeacherDayDate(date.toString())
_uiState.value = _uiState.value.copy(weekStart = schedule.period.weekStart, dayDate = date)
}
}
fun refresh() {
val period = currentPeriod ?: return
val revision = ++loadRevision
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
try {
scheduleRepository.syncPeriod(ScheduleDates.moscowToday(), force = true).collect { }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
}
if (revision != loadRevision) return@launch
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
}
}
fun switchTeacher() {
loadRevision++
viewModelScope.launch {
currentTeacherName = null
currentPeriod = null
scheduleJob?.cancel()
scheduleJob = null
preferencesRepository.clearSelectedTeacher()
_uiState.value = TeacherScheduleUiState(noTeacher = true)
}
}
private fun isInPeriod(date: LocalDate, period: SchedulePeriod): Boolean =
!date.isBefore(period.weekStart) && !date.isAfter(period.weekEnd)
}
data class TeacherScheduleUiState(
val noTeacher: Boolean = true,
val isLoading: Boolean = false,
val loadError: Boolean = false,
val schedule: TeacherSchedule? = null,
val teacherName: String? = null,
val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null,
val weeks: List<SchedulePeriod> = emptyList()
)
@@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIFWjCCA0KgAwIBAgISEdK7udcjGJ5AXwqdLdDfJWfRMA0GCSqGSIb3DQEBDAUAMEYxCzAJBgNV
BAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQDExNHbG9iYWxTaWduIFJv
b3QgUjQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAwMDAwMFowRjELMAkGA1UEBhMCQkUxGTAX
BgNVBAoTEEdsb2JhbFNpZ24gbnYtc2ExHDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBSNDYwggIi
MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCsrHQy6LNl5brtQyYdpokNRbopiLKkHWPd08Es
CVeJOaFV6Wc0dwxu5FUdUiXSE2te4R2pt32JMl8Nnp8semNgQB+msLZ4j5lUlghYruQGvGIFAha/
r6gjA7aUD7xubMLL1aa7DOn2wQL7Id5m3RerdELv8HQvJfTqa1VbkNud316HCkD7rRlr+/fKYIje
2sGP1q7Vf9Q8g+7XFkyDRTNrJ9CG0Bwta/OrffGFqfUo0q3v84RLHIf8E6M6cqJaESvWJ3En7YEt
bWaBkoe0G1h6zD8K+kZPTXhc+CtI4wSEy132tGqzZfxCnlEmIyDLPRT5ge1lFgBPGmSXZgjPjHvj
K8Cd+RTyG/FWaha/LIWFzXg4mutCagI0GIMXTpRW+LaCtfOW3T3zvn8gdz57GSNrLNRyc0NXfeD4
12lPFzYE+cCQYDdF3uYM2HSNrpyibXRdQr4G9dlkbgIQrImwTDsHTUB+JMWKmIJ5jqSngiCNI/on
ccnfxkF0oE32kRbcRoxfKWMxWXEM2G/CtjJ9++ZdU6Z+Ffy7dXxd7Pj2Fxzsx2sZy/N78CsHpdls
eVR2bJ0cpm4O6XkMqCNqo98bMDGfsVR7/mrLZqrcZdCinkqaByFrgY/bxFn63iLABJzjqls2k+g9
vXqhnQt2sQvHnf3PmKgGwvgqo6GDoLclcqUC4wIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYD
VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA1yrc4GHqMywptWU4jaWSf8FmSwwDQYJKoZIhvcNAQEM
BQADggIBAHx47PYCLLtbfpIrXTncvtgdokIzTfnvpCo7RGkerNlFo048p9gkUbJUHJNOxO97k4Vg
JuoJSOD1u8fpaNK7ajFxzHmuEajwmf3lH7wvqMxX63bEIaZHU1VNaL8FpO7XJqti2kM3S+LGteWy
gxk6x9PbTZ4IevPuzz5i+6zoYMzRx6Fcg0XERczzF2sUyQQCPtIkpnnpHs6i58FZFZ8d4kuaPp92
CC1r2LpXFNqD6v6MVenQTqnMdzGxRBF6XLE+0xRFFRhiJBPSy03OXIPBNvIQtQ6IbbjhVp+J3pZm
OUdkLG5NrmJ7v2B0GbhWrJKsFjLtrWhV/pi60zTe9Mlhww6G9kuEYO4Ne7UyWHmRVSyBQ7N0H3qq
JZ4d16GLuc1CLgSkZoNNiTW2bKg2SnkheCLQQrzRQDGQob4Ez8pn7fXwgNNgyYMqIgXQBztSvwye
qiv5u+YfjyW6hY0XHgL+XVAEV8/+LbzvXMAaq7afJMbfc2hIkCwU9D9SGuTSyxTDYWnP4vkYxboz
nxSjBF25cfe1lNj2M8FawTSLfJvdkzrnE6JwYZ+vj+vYxXX4M2bUdGc6N3ec592kD3ZDZopD8p/7
DEJ4Y9HiD2971KE9dJeFt0g5QdYg/NA6s/rob8SKunE3vouXsXgxT7PntgMTzlSdriVZzH81Xwj3
QEUxeCp6
-----END CERTIFICATE-----
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Сертификат vyatsu.ru перевыпущен 05.10.2026 и теперь выдан в иерархии
GlobalSign Root R46, которая в системный trust store Android попала
только в Android 14. На устройствах с Android 13 и ниже все HTTPS-запросы
к сайту падают с "Trust anchor for certification path not found".
Добавляем этот корень как дополнительный якорь доверия ТОЛЬКО для доменов
vyatsu.ru — системные корни сохраняются, остальные сайты не затронуты.
-->
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
<cleartextTrafficPermitted>false</cleartextTrafficPermitted>
</base-config>
<domain-config>
<domain includeSubdomains="true">vyatsu.ru</domain>
<trust-anchors>
<certificates src="system" />
<certificates src="@raw/globalsign_root_r46" />
</trust-anchors>
</domain-config>
</network-security-config>
@@ -0,0 +1,19 @@
package agzam.vyatsu
import agzam.vyatsu.data.remote.GroupsParser
import org.junit.Assert.assertTrue
import org.junit.Test
class GroupsPipelineMemoryTest {
@Test
fun streamingUnderSmallHeap() {
val fixture = checkNotNull(javaClass.getResourceAsStream("/groups.html")) {
"Fixture groups.html not found in src/test/resources"
}
val result = fixture.use(GroupsParser::parseStream)
assertTrue(result.groups.size > 400)
assertTrue(result.groups.any { it.id == "26981" && it.name.contains("ПОДб-1701-08-00") })
assertTrue(result.dateRanges.size > 400)
}
}
@@ -0,0 +1,127 @@
package agzam.vyatsu
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Season
import agzam.vyatsu.domain.model.TeacherSchedule
import agzam.vyatsu.domain.model.TimeSlot
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import java.time.LocalTime
class TeacherScheduleDomainTest {
@Test
fun daysForTeacherFiltersAndPreservesOrder() {
val date1 = LocalDate.of(2026, 9, 14)
val date2 = LocalDate.of(2026, 9, 15)
val slot1 = TimeSlot(
LocalTime.of(8, 20),
LocalTime.of(9, 50),
listOf(lesson(room = "4-302", teacher = "A"), lesson(room = "5-100", teacher = "B"))
)
val slot2 = TimeSlot(
LocalTime.of(10, 0),
LocalTime.of(11, 30),
listOf(lesson(room = "5-100", teacher = "B"))
)
val day2Slot = TimeSlot(
LocalTime.of(8, 20),
LocalTime.of(9, 50),
listOf(lesson(room = "5-100", teacher = "B"))
)
val schedule = TeacherSchedule(
period = SchedulePeriod(date1, date2, Season.AUTUMN),
days = listOf(Day(date1, listOf(slot1, slot2)), Day(date2, listOf(day2Slot)))
)
val result = schedule.daysForTeacher("A")
assertEquals(1, result.size)
assertEquals(date1, result[0].date)
assertEquals(1, result[0].intervals.size)
assertEquals(LocalTime.of(8, 20), result[0].intervals[0].start)
assertEquals(1, result[0].intervals[0].lessons.size)
assertEquals("A", result[0].intervals[0].lessons[0].teacher)
}
@Test
fun daysForTeacherDropsDayWithoutTeacher() {
val date1 = LocalDate.of(2026, 9, 14)
val date2 = LocalDate.of(2026, 9, 15)
val schedule = TeacherSchedule(
period = SchedulePeriod(date1, date2, Season.AUTUMN),
days = listOf(
Day(date1, listOf(slotOf(lesson(room = "4-302", teacher = "A")))),
Day(date2, listOf(slotOf(lesson(room = "5-100", teacher = "B"))))
)
)
val result = schedule.daysForTeacher("A")
assertEquals(1, result.size)
assertEquals(date1, result[0].date)
}
@Test
fun dedupCollapsesOnlyIdenticalLessons() {
val date = LocalDate.of(2026, 9, 14)
val raw = "4-302 Программирование Лекция ИВТб-3304-01-00"
val identical = lesson(
room = "4-302",
group = "ИВТб-3304-01-00",
teacher = "A",
raw = raw,
subject = "Программирование"
)
val differsInGroup = identical.copy(group = "ИВТб-3304-02-00")
val differsInRoom = identical.copy(room = "5-102")
val schedule = TeacherSchedule(
period = SchedulePeriod(date, date, Season.AUTUMN),
days = listOf(
Day(
date,
listOf(
TimeSlot(
LocalTime.of(8, 20),
LocalTime.of(9, 50),
listOf(identical, identical, differsInGroup, differsInRoom)
)
)
)
)
)
val result = schedule.daysForTeacher("A")
val lessons = result[0].intervals[0].lessons
assertEquals(3, lessons.size)
assertEquals(1, lessons.count { it == identical })
assertEquals(1, lessons.count { it.room == "4-302" && it.group == "ИВТб-3304-01-00" })
assertTrue(lessons.any { it.group == "ИВТб-3304-02-00" })
assertTrue(lessons.any { it.room == "5-102" })
}
private fun slotOf(lesson: Lesson) =
TimeSlot(LocalTime.of(8, 20), LocalTime.of(9, 50), listOf(lesson))
private fun lesson(
room: String,
group: String = "ИВТб-3304-01-00",
teacher: String,
raw: String = "$room Программирование Лекция $group",
subject: String = "Программирование"
) = Lesson(
raw = raw,
room = room,
group = group,
subject = subject,
type = LessonType.LECTURE,
teacher = teacher
)
}
@@ -0,0 +1,184 @@
package agzam.vyatsu
import agzam.vyatsu.data.remote.TeacherScheduleParser
import agzam.vyatsu.domain.model.LessonType
import java.time.LocalDate
import java.time.LocalTime
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TeacherScheduleParserTest {
private fun fixture(): String =
checkNotNull(javaClass.getResourceAsStream("/prepod.html"))
.readBytes().toString(Charsets.UTF_8)
private fun fixture517(): String =
checkNotNull(javaClass.getResourceAsStream("/prepod517.html"))
.readBytes().toString(Charsets.UTF_8)
private fun fixture974(): String =
checkNotNull(javaClass.getResourceAsStream("/prepod974.html"))
.readBytes().toString(Charsets.UTF_8)
@Test
fun teachersHeaderParsedInOrderAndDeduped() {
val parsed = TeacherScheduleParser.parse(fixture())
assertEquals(listOf("Лелекова Е.В.", "Иванов И.И.", "Петрова А.С."), parsed.teachers)
}
@Test
fun daysWithCorrectDates() {
val parsed = TeacherScheduleParser.parse(fixture())
assertEquals(2, parsed.days.size)
assertEquals(LocalDate.of(2026, 9, 14), parsed.days[0].date)
assertEquals(LocalDate.of(2026, 9, 15), parsed.days[1].date)
}
@Test
fun intervalTimesParsed() {
val parsed = TeacherScheduleParser.parse(fixture())
val day0 = parsed.days[0]
assertEquals(2, day0.intervals.size)
assertEquals(LocalTime.of(8, 20), day0.intervals[0].start)
assertEquals(LocalTime.of(9, 50), day0.intervals[0].end)
assertEquals(LocalTime.of(10, 0), day0.intervals[1].start)
assertEquals(LocalTime.of(11, 30), day0.intervals[1].end)
}
@Test
fun multiGroupCellSplitsIntoPerGroupLessons() {
val parsed = TeacherScheduleParser.parse(fixture())
val cell = parsed.days[0].intervals[0].lessons.filter { it.teacher == "Лелекова Е.В." }
assertEquals(2, cell.size)
val lesson0 = cell[0]
val lesson1 = cell[1]
assertEquals("Бб-1101-04-00", lesson0.group)
assertNull(lesson0.subgroup)
assertEquals("01 подгруппа", lesson1.subgroup)
assertEquals("4-302", lesson0.room)
assertEquals("Биоразнообразие", lesson0.subject)
assertEquals(LessonType.LECTURE, lesson0.type)
assertEquals("Лелекова Е.В.", lesson0.teacher)
}
@Test
fun emptyCellContributesNoLesson() {
val parsed = TeacherScheduleParser.parse(fixture())
val threeSlots = (0..6).map { slot ->
parsed.days[0].intervals.firstOrNull { it.start.hour == 8 && it.start.minute == 20 } ?: return@map true
}
val petrovLessons = parsed.days[0].intervals[0].lessons.filter { it.teacher == "Петрова А.С." }
assertEquals(0, petrovLessons.size)
assertTrue(threeSlots.isEmpty() || threeSlots.size == 7)
}
@Test
fun noRoomCellKeepsRawAndNullRoom() {
val parsed = TeacherScheduleParser.parse(fixture())
val cell = parsed.days[0].intervals[1].lessons.filter { it.teacher == "Лелекова Е.В." }
assertEquals(2, cell.size)
assertNull(cell[0].room)
assertTrue(cell[0].raw.contains("Бб-1101-04-00"))
assertNull(cell[0].subject)
}
@Test
fun exactTotalLessonCount() {
val parsed = TeacherScheduleParser.parse(fixture())
val total = parsed.days.sumOf { day -> day.intervals.sumOf { it.lessons.size } }
assertEquals(11, total)
}
private fun spacerColumnFixture(): String = """
<table>
<tr>
<td>День</td><td>Интервал</td><td></td>
<td><span>Марков Р.В.</span></td><td><span>Мельцов В.Ю.</span></td>
</tr>
<tr>
<td>четверг 08.10.26</td><td>08:20-09:50</td><td></td>
<td>2-206 Математика Практическое занятие УТб-2301-02-00</td>
<td>1-334 Организация параллельных вычислительных систем Лекция ИВТб-4301-05-00</td>
</tr>
<tr>
<td>10:00-11:30</td><td></td>
<td>2-206 Математика Практическое занятие УТб-2301-02-00</td>
<td>1-334 Организация параллельных вычислительных систем Лекция ИВТб-4301-05-00</td>
</tr>
</table>
""".trimIndent()
@Test
fun emptyHeaderSpacerDoesNotShiftTeacherColumns() {
val parsed = TeacherScheduleParser.parse(spacerColumnFixture())
assertEquals(listOf("Марков Р.В.", "Мельцов В.Ю."), parsed.teachers)
assertEquals(1, parsed.days.size)
assertEquals(LocalDate.of(2026, 10, 8), parsed.days[0].date)
assertEquals(2, parsed.days[0].intervals.size)
for (slot in parsed.days[0].intervals) {
val markov = slot.lessons.filter { it.teacher == "Марков Р.В." }
val meltsov = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
assertEquals(1, markov.size)
assertEquals(1, meltsov.size)
assertEquals("Математика", markov[0].subject)
assertEquals("Организация параллельных вычислительных систем", meltsov[0].subject)
}
}
@Test
fun realPage517TeacherColumnsAligned() {
val html = checkNotNull(javaClass.getResourceAsStream("/prepod517.html"))
.readBytes().toString(Charsets.UTF_8)
val parsed = TeacherScheduleParser.parse(html)
assertTrue(parsed.teachers.contains("Мельцов В.Ю."))
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 8) }
val all = day.intervals.flatMap { it.lessons }
val markov = all.filter { it.teacher == "Марков Р.В." }
val meltsov = all.filter { it.teacher == "Мельцов В.Ю." }
assertTrue("Мельцов должен иметь пары 08.10", meltsov.isNotEmpty())
assertTrue(
"У Мельцова не должно быть математики Маркова",
meltsov.none { it.subject?.contains("Математика") == true }
)
assertTrue(
"У Маркова должна остаться математика",
markov.any { it.subject?.contains("Математика") == true }
)
assertTrue(
"У Мельцова параллельные вычислительные системы",
meltsov.any { it.subject?.contains("Организация параллельных") == true }
)
}
@Test
fun realPage517MeltsovLabsSitAt1000And1145NotAt0820() {
val parsed = TeacherScheduleParser.parse(fixture517())
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 6) }
val labsAt = { time: java.time.LocalTime ->
day.intervals.firstOrNull { it.start == time }
?.lessons
?.filter { it.teacher == "Мельцов В.Ю." && it.type == LessonType.LABORATORY }
.orEmpty()
}
assertEquals(
"В 08:20 у Мельцова на странице 517 лабораторных нет",
0, labsAt(java.time.LocalTime.of(8, 20)).size
)
assertTrue(labsAt(java.time.LocalTime.of(10, 0)).isNotEmpty())
assertTrue(labsAt(java.time.LocalTime.of(11, 45)).isNotEmpty())
}
@Test
fun realPage974MeltsovHasPracticeAt0820() {
val parsed = TeacherScheduleParser.parse(fixture974())
val day = parsed.days.first { it.date == LocalDate.of(2026, 10, 6) }
val slot = day.intervals.first { it.start == java.time.LocalTime.of(8, 20) }
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
assertTrue(mel.isNotEmpty())
assertTrue("08:20 на странице 974 — практическое занятие", mel.all { it.type == LessonType.PRACTICE })
assertEquals("ИВТб-2305-06-00", mel.first().group)
}
}
@@ -0,0 +1,76 @@
package agzam.vyatsu
import agzam.vyatsu.data.remote.TeachersParser
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Season
import java.io.InputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TeachersPipelineMemoryTest {
private fun fixture(): InputStream =
checkNotNull(javaClass.getResourceAsStream("/teacher.html")) {
"Fixture teacher.html not found in src/test/resources"
}
@Test
fun parsesDepartmentsAndFaculties() {
val result = fixture().use(TeachersParser::parseStream)
assertTrue("expected >=4 departments", result.departments.size >= 4)
val ids = result.departments.map { it.departmentId }.toSet()
assertEquals(setOf("11111", "22222", "33333", "44444"), ids)
val names = result.departments.map { it.name }
assertTrue(
"nested tags must be stripped from kafPeriod names",
names.contains("Кафедра технологий и конструирования машин и аппаратов")
)
val avt = result.departments.first { it.departmentId == "11111" }
assertTrue("faculty must be mapped via shortname", avt.faculty.contains("ФАВТ"))
}
@Test
fun chunkBoundarySafe() {
val full = fixture().use(TeachersParser::parseStream)
val limited = limitReads(fixture(), 64).use(TeachersParser::parseStream)
assertEquals(full, limited)
}
@Test
fun seasonAndRanges() {
val result = fixture().use(TeachersParser::parseStream)
val hasAutumn = result.dateRanges.values.any { it[Season.AUTUMN]?.isNotEmpty() == true }
val hasSpring = result.dateRanges.values.any { it[Season.SPRING]?.isNotEmpty() == true }
assertTrue("AUTUMN season must be present", hasAutumn)
assertTrue("SPRING season must be present", hasSpring)
val autumn = result.dateRanges["11111"]?.get(Season.AUTUMN).orEmpty()
assertTrue(
"DateRange must be parsed from URL groups",
autumn.contains(DateRange("01092026", "13092026"))
)
assertTrue(
"second AUTUMN range must be present",
autumn.contains(DateRange("14092026", "27092026"))
)
val spring = result.dateRanges["44444"]?.get(Season.SPRING).orEmpty()
assertTrue(
"SPRING DateRange must be parsed from URL groups",
spring.contains(DateRange("02022026", "27022026"))
)
}
private fun limitReads(input: InputStream, maxBytes: Int): InputStream =
object : InputStream() {
override fun read(): Int = input.read()
override fun read(b: ByteArray, off: Int, len: Int): Int =
input.read(b, off, minOf(len, maxBytes))
}
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
<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
+980
View File
@@ -0,0 +1,980 @@
<!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;">Обновление&nbsp;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;">Долженкова&nbsp;М.Л.</SPAN></TD>
<TD CLASS="R1C2"><SPAN STYLE="white-space:nowrap;max-width:0px;">Коржавина&nbsp;А.С.</SPAN></TD>
<TD CLASS="R1C2"><SPAN STYLE="white-space:nowrap;max-width:0px;">Мельцов&nbsp;В.Ю.</SPAN></TD>
<TD CLASS="R1C2"><SPAN STYLE="white-space:nowrap;max-width:0px;">Прозоров&nbsp;Д.Е.</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;">понедельник&nbsp;28.09.26&nbsp;</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;">вторник&nbsp;29.09.26&nbsp;</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;">среда&nbsp;30.09.26&nbsp;</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;">четверг&nbsp;01.10.26&nbsp;</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;">пятница&nbsp;02.10.26&nbsp;</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;">суббота&nbsp;03.10.26&nbsp;</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;">понедельник&nbsp;05.10.26&nbsp;</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;">вторник&nbsp;06.10.26&nbsp;</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;">среда&nbsp;07.10.26&nbsp;</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;">четверг&nbsp;08.10.26&nbsp;</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;">пятница&nbsp;09.10.26&nbsp;</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;">суббота&nbsp;10.10.26&nbsp;</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;">&nbsp;</DIV></TD>
</TR>
</TABLE>
</BODY>
</HTML>
+90
View File
@@ -0,0 +1,90 @@
<!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>