Compare commits

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

Add debug logging for teacher schedule sync/build and debounce teacher
search in picker.
2026-09-20 15:13:27 +03:00
Agzamus ef81659521 chore(http): log failed requests only
Per-request GET success logs (39 long URLs per teacher sync) flood the
2MB logcat buffer together with system ZeroHung spam, drowning the real
kill reason (am_proc_died / lowmemorykiller). Log only non-2xx responses.
2026-09-20 03:08:33 +03:00
Agzamus 6189540acf perf(teacher): lower sync memory footprint
- Reduce sync parallelism 5 -> 2 to cap peak heap while bulk-fetching
  all departments for a period
- Replace per-write rows (insert schedule/days/lessons separately) with a
  single @Transaction replaceSchedule() so Room invalidates observers once
  per department instead of ~4x each, cutting main-thread recompositions
  and intermediate allocations during the 38-department sync
2026-09-20 02:43:54 +03:00
Agzamus 1d55e84e96 fix(teacher): register migration, containment period queries, concurrency guards
- Register AppDatabase.MIGRATION_3_4 so v3 databases upgrade without data loss
- Query teacher schedule periods by containment (weekStart<=x AND weekEnd>=x)
  instead of exact weekStart, matching 2-week catalog windows
- Clip buildTeacherSchedule days to the requested week range
- Add weeksFor() to enumerate weeks of a catalog window for the UI selector
- Serialize syncPeriod body with a Mutex to protect the unique index
- Rethrow CancellationException before generic catch; version loadRevision
  checks so stale observers/loads are cancelled
- Sync job tracking in PickerViewModel; keep loading true while empty
- Drop dead selectTeacher helper
2026-09-19 23:32:09 +03:00
Agzamus 39e4f5a04f fix(remote): replace unsupported (?U)\b regex for Android runtime 2026-09-19 20:44:22 +03:00
Agzamus e1b2f05548 feat(ui): integrate teacher mode into AppRoot and Home 2026-09-19 20:28:13 +03:00
Agzamus f5696fbbd6 feat(ui): add combined group/teacher picker with segments 2026-09-19 20:16:41 +03:00
Agzamus b9b3a18f0c feat(di): wire teacher repositories into AppContainer 2026-09-19 19:37:56 +03:00
Agzamus 2dd8b8c469 feat(ui): add teacher schedule screen and view model 2026-09-19 19:12:19 +03:00
Agzamus 6c06892d9a feat(data): add TeacherScheduleRepositoryImpl with bulk period sync 2026-09-19 19:12:19 +03:00
Agzamus 9dce30931b feat(data): add TeacherRepositoryImpl 2026-09-19 15:59:25 +03:00
Agzamus 32c2b60afa feat(remote): add jsoup TeacherScheduleParser 2026-09-19 14:47:55 +03:00
Agzamus 458e4c4e69 feat(remote): add streaming TeachersParser 2026-09-19 14:47:46 +03:00
Agzamus 1a4bff73d0 feat(db): add teacher schedule tables and Room v4 migration 2026-09-19 14:47:35 +03:00
Agzamus a5ea516713 refactor(ui): extract shared schedule composables 2026-09-19 01:47:29 +03:00
Agzamus 17d437aace feat(domain): add teacher models and repository interfaces 2026-09-19 01:43:17 +03:00
vantablackTea 1077be1897 fix: possible moshi deleting due to proguard 2026-09-18 11:36:25 +03:00
vantablackTea 96db4fa3a6 fix: reverting separate release versions by architecture 2026-09-18 10:01:02 +03:00
vantablackTea 830b4664fb fix: added proguard rules 2026-09-18 09:48:14 +03:00
vantablackTea dd4fb90dc4 fix: testing build optimisation 2026-09-18 09:30:19 +03:00
Agzamus a55fb0461c ci: debug build name 2026-09-18 02:30:56 +03:00
Agzamus e789729c76 ui: removed version info from buildings screen 2026-09-18 02:16:46 +03:00
Agzamus 7e23895f0d fix: version check 2026-09-18 02:16:29 +03:00
Agzamus 708ad11dca ci: release same tag & version 2026-09-18 01:28:48 +03:00
Agzamus b1418f79da fix: immediately scrolling 2026-09-18 01:25:00 +03:00
Agzamus d17c70e871 feat: schedule swiping 2026-09-18 01:20:47 +03:00
56 changed files with 21760 additions and 599 deletions
+2 -2
View File
@@ -74,9 +74,9 @@ jobs:
SUFFIX=$((SUFFIX + 1))
TAG="v${BASE}.${SUFFIX}"
done
echo "version_name=$BASE" >> "$GITHUB_OUTPUT"
echo "version_name=$TAG" >> "$GITHUB_OUTPUT"
echo "version_tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Generated version: $BASE (tag: $TAG)"
echo "Generated version: $BASE"
- name: Restore keystore from secret
# Ключ подписи передаётся как base64-секрет и декодируется в файл.
+2 -1
View File
@@ -40,7 +40,8 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
+19
View File
@@ -19,3 +19,22 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
# Игнорировать отсутствие класса декодера JP2 в PdfBox-Android
-dontwarn com.gemalto.jp2.JP2Decoder
# На всякий случай, если PdfBox ссылается на другие отсутствующие библиотеки (например, ICU для текста)
-dontwarn com.tom_roush.pdfbox.**
# Запретить R8 удалять и переименовывать внутренности PdfBox-Android
-keep class com.tom_roush.pdfbox.** { *; }
-keep interface com.tom_roush.pdfbox.** { *; }
# Moshi удаляется proguard
-keepnames @com.squareup.moshi.JsonClass class *
-keep class **JsonAdapter {
public <init>(...);
<fields>;
}
-keepclassmembers @com.squareup.moshi.JsonClass class * {
synthetic <init>(...);
}
@@ -0,0 +1,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')"
]
}
}
+23 -9
View File
@@ -1,11 +1,12 @@
package agzam.vyatsu
import agzam.vyatsu.di.AppContainer
import agzam.vyatsu.ui.groups.GroupPickerScreen
import agzam.vyatsu.ui.groups.GroupPickerViewModel
import agzam.vyatsu.ui.home.HomeScreen
import agzam.vyatsu.ui.picker.PickerScreen
import agzam.vyatsu.ui.picker.PickerViewModel
import agzam.vyatsu.ui.schedule.ScheduleViewModel
import agzam.vyatsu.ui.settings.UpdateViewModel
import agzam.vyatsu.ui.teacher.TeacherScheduleViewModel
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -23,9 +24,11 @@ fun AppRoot() {
val container = app.container
val scheduleViewModel: ScheduleViewModel = viewModel(factory = scheduleFactory(container))
val teacherScheduleViewModel: TeacherScheduleViewModel = viewModel(factory = teacherScheduleFactory(container))
val updateViewModel: UpdateViewModel = viewModel(factory = updateFactory(container))
val state by scheduleViewModel.uiState.collectAsState()
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
val showFirstRunDialog by updateViewModel.showFirstRunDialog.collectAsState()
if (showFirstRunDialog) {
@@ -51,18 +54,21 @@ fun AppRoot() {
)
}
if (state.noGroup) {
val groupPickerViewModel: GroupPickerViewModel = viewModel(factory = groupPickerFactory(container))
GroupPickerScreen(
viewModel = groupPickerViewModel,
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) }
if (state.noGroup && teacherState.noTeacher) {
val pickerViewModel: PickerViewModel = viewModel(factory = pickerFactory(container))
PickerScreen(
viewModel = pickerViewModel,
onGroupPicked = { group -> scheduleViewModel.selectGroup(group.id) },
onTeacherPicked = { teacher -> teacherScheduleViewModel.selectTeacher(teacher.name) }
)
return
}
HomeScreen(
scheduleViewModel = scheduleViewModel,
teacherScheduleViewModel = teacherScheduleViewModel,
updateViewModel = updateViewModel,
onSwitchGroup = { scheduleViewModel.switchGroup() }
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 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) }
@@ -3,6 +3,8 @@ package agzam.vyatsu.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(
entities = [
@@ -11,9 +13,14 @@ import androidx.room.TypeConverters
ScheduleEntity::class,
DayEntity::class,
LessonEntity::class,
PreferenceEntity::class
PreferenceEntity::class,
DepartmentEntity::class,
DepartmentPeriodEntity::class,
TeacherScheduleEntity::class,
TeacherDayEntity::class,
TeacherLessonEntity::class
],
version = 3,
version = 4,
exportSchema = true
)
@TypeConverters(Converters::class)
@@ -21,4 +28,91 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun groupDao(): GroupDao
abstract fun scheduleDao(): ScheduleDao
abstract fun preferencesDao(): PreferencesDao
abstract fun teacherDao(): TeacherDao
abstract fun teacherScheduleDao(): TeacherScheduleDao
companion object {
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE TABLE IF NOT EXISTS `departments` (" +
"`departmentId` TEXT NOT NULL, `name` TEXT NOT NULL, " +
"`faculty` TEXT NOT NULL, `lastSyncedAt` INTEGER NOT NULL, " +
"PRIMARY KEY(`departmentId`))"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `department_periods` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, " +
"`weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, " +
"FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_department_periods_departmentId` " +
"ON `department_periods` (`departmentId`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `teacher_schedules` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`departmentId` TEXT NOT NULL, `season` TEXT NOT NULL, " +
"`weekStart` INTEGER NOT NULL, `weekEnd` INTEGER NOT NULL, " +
"`lastSyncedAt` INTEGER NOT NULL, " +
"FOREIGN KEY(`departmentId`) REFERENCES `departments`(`departmentId`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId` " +
"ON `teacher_schedules` (`departmentId`)"
)
db.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS `index_teacher_schedules_departmentId_season_weekStart` " +
"ON `teacher_schedules` (`departmentId`, `season`, `weekStart`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `teacher_days` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`scheduleId` INTEGER NOT NULL, `date` INTEGER NOT NULL, " +
"FOREIGN KEY(`scheduleId`) REFERENCES `teacher_schedules`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_days_scheduleId` " +
"ON `teacher_days` (`scheduleId`)"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_days_date` " +
"ON `teacher_days` (`date`)"
)
db.execSQL(
"CREATE TABLE IF NOT EXISTS `teacher_lessons` (" +
"`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
"`dayId` INTEGER NOT NULL, `date` INTEGER NOT NULL, " +
"`slotIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, " +
"`end` INTEGER NOT NULL, `lessonIndex` INTEGER NOT NULL, " +
"`raw` TEXT NOT NULL, `room` TEXT, `groupName` TEXT, " +
"`subgroup` TEXT, `subject` TEXT, `type` TEXT NOT NULL, " +
"`teacher` TEXT NOT NULL, " +
"FOREIGN KEY(`dayId`) REFERENCES `teacher_days`(`id`) " +
"ON UPDATE NO ACTION ON DELETE CASCADE )"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_dayId` " +
"ON `teacher_lessons` (`dayId`)"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_teacher` " +
"ON `teacher_lessons` (`teacher`)"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_room` " +
"ON `teacher_lessons` (`room`)"
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_teacher_lessons_subject` " +
"ON `teacher_lessons` (`subject`)"
)
}
}
}
}
@@ -0,0 +1,12 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "departments")
data class DepartmentEntity(
@PrimaryKey val departmentId: String,
val name: String,
val faculty: String,
val lastSyncedAt: Long
)
@@ -0,0 +1,28 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
// Все опубликованные на сайте 2-недельные периоды кафедры, включая ещё не начавшиеся.
@Entity(
tableName = "department_periods",
foreignKeys = [
ForeignKey(
entity = DepartmentEntity::class,
parentColumns = ["departmentId"],
childColumns = ["departmentId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("departmentId")]
)
data class DepartmentPeriodEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val departmentId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long
)
@@ -28,6 +28,36 @@ interface PreferencesDao {
@Query("INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedDayDate', :date)")
suspend fun setSelectedDayDate(date: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherId'")
suspend fun selectedTeacherId(): String?
@Query(
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherId', :teacherId)"
)
suspend fun setSelectedTeacherId(teacherId: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherWeekStart'")
suspend fun selectedTeacherWeekStart(): String?
@Query(
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherWeekStart', :weekStart)"
)
suspend fun setSelectedTeacherWeekStart(weekStart: String)
@Query("SELECT value FROM preferences WHERE key = 'selectedTeacherDayDate'")
suspend fun selectedTeacherDayDate(): String?
@Query(
"INSERT OR REPLACE INTO preferences(key, value) VALUES('selectedTeacherDayDate', :date)"
)
suspend fun setSelectedTeacherDayDate(date: String)
@Query(
"DELETE FROM preferences WHERE key IN " +
"('selectedTeacherId', 'selectedTeacherWeekStart', 'selectedTeacherDayDate')"
)
suspend fun clearSelectedTeacher()
@Query("SELECT value FROM preferences WHERE key = 'autoUpdateEnabled'")
suspend fun autoUpdateEnabled(): String?
@@ -0,0 +1,39 @@
package agzam.vyatsu.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface TeacherDao {
@Query("SELECT * FROM departments ORDER BY name")
fun observeDepartments(): Flow<List<DepartmentEntity>>
@Query("SELECT * FROM departments WHERE departmentId = :id")
suspend fun getDepartment(id: String): DepartmentEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(departments: List<DepartmentEntity>)
@Query("SELECT MAX(lastSyncedAt) FROM departments")
suspend fun maxSyncedAt(): Long?
@Query("SELECT DISTINCT faculty FROM departments ORDER BY faculty")
suspend fun getFaculties(): List<String>
@Query("DELETE FROM department_periods")
suspend fun deleteAllPeriods()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPeriods(periods: List<DepartmentPeriodEntity>)
@Query(
"SELECT * FROM department_periods WHERE weekStart <= :epochDay AND weekEnd >= :epochDay ORDER BY departmentId"
)
suspend fun getPeriodsForDate(epochDay: Long): List<DepartmentPeriodEntity>
@Query("SELECT * FROM department_periods WHERE departmentId = :departmentId ORDER BY weekStart")
suspend fun getPeriodsForDepartment(departmentId: String): List<DepartmentPeriodEntity>
}
@@ -0,0 +1,27 @@
package agzam.vyatsu.data.local
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_days",
foreignKeys = [
ForeignKey(
entity = TeacherScheduleEntity::class,
parentColumns = ["id"],
childColumns = ["scheduleId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("scheduleId"),
Index("date")
]
)
data class TeacherDayEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val scheduleId: Long,
val date: Long
)
@@ -0,0 +1,41 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.LessonType
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_lessons",
foreignKeys = [
ForeignKey(
entity = TeacherDayEntity::class,
parentColumns = ["id"],
childColumns = ["dayId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("dayId"),
Index("teacher"),
Index("room"),
Index("subject")
]
)
data class TeacherLessonEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val dayId: Long,
val date: Long,
val slotIndex: Int,
val start: Int,
val end: Int,
val lessonIndex: Int,
val raw: String,
val room: String?,
val groupName: String?,
val subgroup: String?,
val subject: String?,
val type: LessonType,
val teacher: String
)
@@ -0,0 +1,81 @@
package agzam.vyatsu.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
@Dao
interface TeacherScheduleDao {
@Query("SELECT * FROM teacher_schedules ORDER BY weekStart DESC")
fun observeSchedules(): Flow<List<TeacherScheduleEntity>>
@Query("SELECT * FROM teacher_schedules ORDER BY weekStart DESC")
suspend fun getSchedules(): List<TeacherScheduleEntity>
@Query("DELETE FROM teacher_days WHERE scheduleId = :scheduleId")
suspend fun deleteDaysForSchedule(scheduleId: Long)
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertSchedule(schedule: TeacherScheduleEntity): Long
@Query(
"DELETE FROM teacher_schedules WHERE departmentId = :departmentId AND season = :season AND weekStart = :weekStart"
)
suspend fun deleteSchedule(departmentId: String, season: String, weekStart: Long)
@Query("DELETE FROM teacher_schedules WHERE season = :season AND weekStart = :weekStart")
suspend fun deletePeriod(season: String, weekStart: Long)
@Query("DELETE FROM teacher_schedules")
suspend fun deleteAll()
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertDays(days: List<TeacherDayEntity>): List<Long>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertLessons(lessons: List<TeacherLessonEntity>)
@Transaction
suspend fun replaceSchedule(schedule: TeacherScheduleEntity, days: List<TeacherDayEntity>, lessons: List<TeacherLessonEntity>) {
val scheduleId = insertSchedule(schedule)
val daysWithScheduleId = days.map { it.copy(scheduleId = scheduleId) }
deleteDaysForSchedule(scheduleId)
val dayIds = insertDays(daysWithScheduleId)
val dayIdByDate = daysWithScheduleId.zip(dayIds).associate { (day, id) -> day.date to id }
insertLessons(lessons.map { lesson -> lesson.copy(dayId = dayIdByDate.getValue(lesson.date)) })
}
@Query(
"SELECT teacher_days.* FROM teacher_days INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher_days.date"
)
fun observeDaysForPeriod(season: String, weekStart: Long): Flow<List<TeacherDayEntity>>
@Query(
"SELECT teacher_lessons.* FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart " +
"ORDER BY teacher_lessons.date, teacher_lessons.slotIndex, teacher_lessons.lessonIndex"
)
fun observeLessonsForPeriod(season: String, weekStart: Long): Flow<List<TeacherLessonEntity>>
@Query(
"SELECT DISTINCT teacher FROM teacher_lessons INNER JOIN teacher_days ON teacher_days.id = teacher_lessons.dayId " +
"INNER JOIN teacher_schedules ON teacher_schedules.id = teacher_days.scheduleId " +
"WHERE teacher_schedules.season = :season AND teacher_schedules.weekStart <= :weekStart " +
"AND teacher_schedules.weekEnd >= :weekStart ORDER BY teacher"
)
fun observeDistinctTeachers(season: String, weekStart: Long): Flow<List<String>>
@Query(
"SELECT EXISTS(SELECT 1 FROM teacher_schedules WHERE season = :season " +
"AND weekStart <= :weekStart AND weekEnd >= :weekStart)"
)
suspend fun hasSchedulesForPeriod(season: String, weekStart: Long): Boolean
}
@@ -0,0 +1,31 @@
package agzam.vyatsu.data.local
import agzam.vyatsu.domain.model.Season
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "teacher_schedules",
foreignKeys = [
ForeignKey(
entity = DepartmentEntity::class,
parentColumns = ["departmentId"],
childColumns = ["departmentId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("departmentId"),
Index(value = ["departmentId", "season", "weekStart"], unique = true)
]
)
data class TeacherScheduleEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val departmentId: String,
val season: Season,
val weekStart: Long,
val weekEnd: Long,
val lastSyncedAt: Long
)
@@ -0,0 +1,36 @@
package agzam.vyatsu.data.remote
/**
* Справочник сокращённых названий факультетов ВятГУ.
* Вынесен из GroupsParser, так как TeachersParser использует те же сокращения.
*/
object FacultyNames {
private val parenthesized = Regex("""\(.*?\)\s?""")
private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
"Факультет лингвистики (ОРУ)" to "ФЛ",
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
"Электротехнический факультет (ОРУ)" to "ЭТФ",
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
)
fun shortname(name: String): String {
val stripped = removeParenthesized(name)
val sh = shorthands[name]
return if (sh != null) "$sh ($stripped)" else stripped
}
fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
}
@@ -14,30 +14,11 @@ object GroupsParser {
private val groupPattern = Regex("""/reports/schedule/Group/(\d{4,})_([12])_(\d{8})_(\d{8})\.pdf""")
private val grpIdPattern = Regex("""data-grp_period_id="(\d+)"""")
private val parenthesized = Regex("""\(.*?\)\s?""")
private const val CHUNK_SIZE = 8192
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
private val shorthands = mapOf(
"Институт биологии и биотехнологии (факультет)(ОРУ)" to "ИББТ",
"Институт химии и экологии (факультет) (ОРУ)" to "ИнХимЭк",
"Факультет автоматики и вычислительной техники (ОРУ)" to "ФАВТ",
"Факультет истории, политических наук и культурологии (ОРУ)" to "ФИПНК",
"Факультет компьютерных и физико-математических наук (ОРУ)" to "ФКФМН",
"Факультет лингвистики (ОРУ)" to "ФЛ",
"Факультет менеджмента и сервиса (ОРУ)" to "ФМиС",
"Факультет педагогики и психологии (ОРУ)" to "ФПП",
"Факультет строительства и архитектуры (ОРУ)" to "ФСА",
"Факультет технологий, инжиниринга и дизайна (ОРУ)" to "ФТИД",
"Факультет физической культуры и спорта (ОРУ)" to "ФФКС",
"Факультет филологии и медиакоммуникаций (ОРУ)" to "ФФМ",
"Факультет экономики и финансов (ОРУ)" to "ФЭиФ",
"Электротехнический факультет (ОРУ)" to "ЭТФ",
"Юридический институт (факультет) (ОРУ)" to "ЮИ"
)
/**
* Стриминговый парсер страницы групп: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
@@ -80,8 +61,7 @@ object GroupsParser {
else -> minOf(divAt, aAt)
}
if (tagStart < 0) {
// хвостовой текст без div-тегов — обычный текст страницы
pos = pending.length
pos = keepPartialTagTail(pending, pos)
break
}
if (tagStart > pos) {
@@ -101,13 +81,13 @@ object GroupsParser {
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val name = content.trim()
if (name.isNotEmpty()) faculty = shortname(name)
if (name.isNotEmpty()) faculty = FacultyNames.shortname(name)
} else {
val m = grpIdPattern.find(tagText)
if (m != null) {
groups += Group(
id = m.groupValues[1].dropLast(1),
name = removeParenthesized(content),
name = FacultyNames.removeParenthesized(content),
faculty = faculty
)
}
@@ -153,13 +133,22 @@ object GroupsParser {
return -1
}
private fun shortname(name: String): String {
val stripped = removeParenthesized(name)
val sh = shorthands[name]
return if (sh != null) "$sh ($stripped)" else stripped
/**
* Хвост буфера может содержать неполное начало тега (<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
}
private fun removeParenthesized(name: String): String = name.replace(parenthesized, "").trim()
}
data class ParsedGroups(
@@ -0,0 +1,145 @@
package agzam.vyatsu.data.remote
import agzam.vyatsu.data.remote.pdf.TextPatterns
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.TimeSlot
import android.util.Log
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
object TeacherScheduleParser {
private const val TAG = "TeacherParserDebug"
private val DAY_REGEX = Regex("""^(понедельник|вторник|среда|четверг|пятница|суббота|воскресенье)\s+(\d{2}\.\d{2}\.\d{2})$""")
private val INTERVAL_REGEX = Regex("""^(\d{2}:\d{2})-(\d{2}:\d{2})$""")
private val GROUP_TOKEN = Regex("""(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}])""")
private val SPLIT_BY_GROUP = Regex("""(?=(?<![\p{L}\p{N}])[А-ЯЁа-яё]{1,4}-\d{2,4}-\d{2}-\d{2}(?![\p{L}\p{N}]))""")
private val DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yy")
data class ParsedTeacherSchedule(val teachers: List<String>, val days: List<Day>)
fun parse(html: String): ParsedTeacherSchedule {
val doc = Jsoup.parse(html)
var headerFound = false
var teachers: List<String> = emptyList()
val days = mutableListOf<Day>()
var currentDate: LocalDate? = null
var currentIntervals: MutableList<TimeSlot>? = null
fun flushDay() {
val date = currentDate ?: return
val intervals = currentIntervals ?: return
if (intervals.isNotEmpty()) days.add(Day(date, intervals.toList()))
}
for (tr in doc.select("tr")) {
val tds = tr.children().filter { it.tagName() == "td" }
if (tds.size < 2) continue
val first = cellText(tds[0])
val second = cellText(tds[1])
if (!headerFound && first == "День" && second == "Интервал") {
teachers = tds.drop(2)
.map { cellText(it.selectFirst("span") ?: it) }
.filter { it.isNotEmpty() }
.distinct()
headerFound = true
continue
}
val dayMatch = DAY_REGEX.matchEntire(first)
val intervalMatch = INTERVAL_REGEX.matchEntire(if (dayMatch != null) second else first)
if (dayMatch != null) {
flushDay()
currentDate = LocalDate.parse(dayMatch.groupValues[2], DATE_FMT)
currentIntervals = mutableListOf()
}
if (intervalMatch != null && currentIntervals != null) {
parseIntervalRow(tds, intervalMatch, if (dayMatch != null) 2 else 1, teachers, currentIntervals!!)
}
}
flushDay()
Log.d(TAG, "parse: teachers=$teachers days=${days.size}")
for (day in days) {
Log.d(TAG, " parse ${day.date}: intervals=${day.intervals.size}")
for (slot in day.intervals) {
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
val teachersInSlot = slot.lessons.map { it.teacher }.distinct()
Log.d(
TAG,
" slot ${slot.start}-${slot.end} total=${slot.lessons.size} meltsov=${mel.size} teachers=$teachersInSlot"
)
mel.forEach { l ->
Log.d(TAG, " mel: room=${l.room} group=${l.group} subj=${l.subject} type=${l.type} raw=${l.raw}")
}
}
}
return ParsedTeacherSchedule(teachers, days)
}
private fun parseIntervalRow(
tds: List<Element>,
intervalMatch: MatchResult,
startCol: Int,
teachers: List<String>,
intervals: MutableList<TimeSlot>
) {
val start = LocalTime.parse(intervalMatch.groupValues[1])
val end = LocalTime.parse(intervalMatch.groupValues[2])
val lessons = mutableListOf<Lesson>()
for (i in teachers.indices) {
val cell = tds.getOrNull(startCol + i) ?: continue
lessons += parseCell(cell, teachers[i])
}
if (lessons.isNotEmpty()) intervals.add(TimeSlot(start, end, lessons))
}
private fun parseCell(cell: Element, teacher: String): List<Lesson> {
val raw = cellText(cell)
if (raw.isEmpty()) return emptyList()
val room = TextPatterns.ROOM_REGEX.find(raw)?.value
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.find(raw)
val type = LessonType.fromRaw(typeMatch?.value?.trim() ?: "")
val segments = raw.split(SPLIT_BY_GROUP).map { it.trim() }.filter { it.isNotEmpty() }
val groupSegments = segments.filter { GROUP_TOKEN.containsMatchIn(it) }
if (groupSegments.isEmpty()) {
return listOf(
Lesson(
raw = raw,
room = room,
subject = subjectOf(raw, room, typeMatch),
type = type,
teacher = teacher
)
)
}
val subject = subjectOf(raw, room, typeMatch)
return groupSegments.flatMap { segment ->
val groups = GROUP_TOKEN.findAll(segment).map { it.value }.distinct().toList()
val subgroup = TextPatterns.SUBGROUP_REGEX.find(segment)?.value
groups.map { group ->
Lesson(
raw = segment,
room = room,
group = group,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
}
}
}
private fun subjectOf(raw: String, room: String?, typeMatch: MatchResult?): String? {
val end = typeMatch?.range?.first ?: GROUP_TOKEN.find(raw)?.range?.first ?: raw.length
val prefix = raw.substring(0, end).trim()
val stripped = room?.let { prefix.removePrefix(it).trim() } ?: prefix
return stripped.ifEmpty { null }
}
private fun cellText(el: Element): String = el.text().replace('\u00A0', ' ').trim()
}
@@ -0,0 +1,148 @@
package agzam.vyatsu.data.remote
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Department
import agzam.vyatsu.domain.model.ParsedDepartments
import agzam.vyatsu.domain.model.Season
import java.io.InputStream
/**
* Стриминговый парсер страницы преподавателей: читает тело ответа по чанкам и
* разбирает его на лету, не материализуя весь HTML в память.
* Страница teacher.html весит ~2.4MB, поэтому весь документ грузить нельзя.
*/
object TeachersParser {
private val fakNameTag = Regex("""<div[^>]*class="fak_name"""")
private val fakIdInTag = Regex("""data-fak_id="(\d+)"""")
private val kafPeriodTag = Regex("""<div[^>]*class="kafPeriod"""")
private val kafIdInTag = Regex("""data-kaf_period_id="(\d+)"""")
private val prepodLink = Regex("""href="/reports/schedule/prepod/(\d+)_([12])_(\d{8})_(\d{8})\.html"""")
private val tagStrip = Regex("""<[^>]*>""")
private const val CHUNK_SIZE = 8192
private const val MAX_SLICE = 1 shl 16 // 64 KiB — защита от аномально длинных единиц
private const val MAX_PENDING = 1 shl 20 // 1 MiB — жёсткий потолок буфера
fun parseStream(input: InputStream): ParsedDepartments {
val reader = input.bufferedReader(Charsets.UTF_8)
val buf = CharArray(CHUNK_SIZE)
val pending = StringBuilder(CHUNK_SIZE * 2)
val departments = ArrayList<Department>()
val ranges = HashMap<String, HashMap<Season, MutableList<DateRange>>>()
var faculty = ""
fun addRanges(text: String) {
for (m in prepodLink.findAll(text)) {
val departmentId = m.groupValues[1]
val season = if (m.groupValues[2] == "1") Season.AUTUMN else Season.SPRING
val range = DateRange(m.groupValues[3], m.groupValues[4])
ranges.getOrPut(departmentId) {
hashMapOf(Season.AUTUMN to ArrayList(), Season.SPRING to ArrayList())
}.getOrPut(season) { ArrayList() }.add(range)
}
}
while (true) {
val n = reader.read(buf)
if (n < 0) break
pending.append(buf, 0, n)
var pos = 0
while (pos < pending.length) {
val divAt = pending.indexOf("<div", pos)
val aAt = pending.indexOf("<a", pos)
val tagStart = when {
divAt < 0 -> aAt
aAt < 0 -> divAt
else -> minOf(divAt, aAt)
}
if (tagStart < 0) {
pos = keepPartialTagTail(pending, pos)
break
}
if (tagStart > pos) {
addRanges(pending.substring(pos, tagStart))
}
val tagEnd = findTagEnd(pending, tagStart)
if (tagEnd < 0) break
val tagText = pending.substring(tagStart, tagEnd + 1)
val isDiv = tagText.startsWith("<div")
val isFakName = isDiv && fakNameTag.containsMatchIn(tagText)
val isKafPeriod = isDiv && kafPeriodTag.containsMatchIn(tagText)
if (isFakName || isKafPeriod) {
val closeDiv = pending.indexOf("</div>", tagEnd + 1)
if (closeDiv < 0) break
addRanges(tagText)
val content = pending.substring(tagEnd + 1, closeDiv)
if (isFakName) {
val stripped = tagStrip.replace(content, "").trim()
if (stripped.isNotEmpty()) faculty = FacultyNames.shortname(stripped)
} else {
val m = kafIdInTag.find(tagText)
if (m != null) {
departments += Department(
departmentId = m.groupValues[1].dropLast(1),
name = tagStrip.replace(content, "").trim(),
faculty = faculty
)
}
}
pos = closeDiv + 6
} else {
addRanges(tagText)
pos = tagEnd + 1
}
if (pos > MAX_SLICE) {
pending.delete(0, pos)
pos = 0
}
}
if (pos > 0 && pos <= pending.length) {
pending.delete(0, pos)
}
if (pending.length > MAX_PENDING) {
pending.delete(0, pending.length - MAX_SLICE)
}
}
return ParsedDepartments(departments, ranges)
}
/** Ищет конец HTML-тега — символ '>' вне кавычек. */
private fun findTagEnd(s: CharSequence, start: Int): Int {
var i = start
var quote = 0.toChar()
while (i < s.length) {
val c = s[i]
if (quote != 0.toChar()) {
if (c == quote) quote = 0.toChar()
} else {
when (c) {
'"', '\'' -> quote = c
'>' -> return i
}
}
i++
}
return -1
}
/**
* Хвост буфера может содержать неполное начало тега (<div... разорванный границей чанка).
* Сдвиг перемотки назад, чтобы оставить такой префикс в pending до прихода следующего чанка.
*/
private fun keepPartialTagTail(pending: CharSequence, from: Int): Int {
var i = from
while (i < pending.length) {
val c = pending[i]
if (c == '<') {
val tail = pending.substring(i, pending.length)
if (tail == "<" || tail == "<d" || tail == "<di" || tail == "<a") return i
}
i++
}
return pending.length
}
}
@@ -22,7 +22,7 @@ object VyatsuHttpClient {
suspend fun getText(url: String): String = withContext(Dispatchers.IO) {
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
Log.d(TAG, "GET $url -> ${resp.code}")
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
resp.body!!.string()
}
@@ -30,7 +30,7 @@ object VyatsuHttpClient {
suspend fun getBytes(url: String): ByteArray = withContext(Dispatchers.IO) {
client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
Log.d(TAG, "GET $url -> ${resp.code}")
if (!resp.isSuccessful) Log.e(TAG, "GET $url -> HTTP ${resp.code}")
check(resp.isSuccessful) { "HTTP ${resp.code} for $url" }
resp.body!!.bytes()
}
@@ -42,7 +42,7 @@ object VyatsuHttpClient {
*/
suspend fun openStream(url: String): InputStream = withContext(Dispatchers.IO) {
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" }
resp.body!!.byteStream()
}
@@ -10,4 +10,9 @@ object VyatsuUrls {
fun groupPdfUrl(groupId: String, season: Season, range: DateRange): String = "$PDF_BASEURL/${groupId}_${season.apiKey}_${range.first}_${range.second}.pdf"
const val TEACHER_PAGE = "https://www.vyatsu.ru/studentu-1/spravochnaya-informatsiya/teacher.html"
const val PREPOD_BASEURL = "https://www.vyatsu.ru/reports/schedule/prepod"
fun teacherPrepodUrl(departmentId: String, season: Season, range: DateRange): String = "$PREPOD_BASEURL/${departmentId}_${season.apiKey}_${range.first}_${range.second}.html"
}
@@ -26,10 +26,6 @@ object PdfScheduleParser {
private fun timeFrom(timeString: String?): LocalTime? { return runCatching { LocalTime.parse(timeString, TIME_FORMAT) }.getOrNull() }
private fun dateFrom(dateString: String?): LocalDate? { return runCatching { LocalDate.parse(dateString, DATE_FORMAT) }.getOrNull() }
private val ROOM_REGEX = Regex("""(?<=^|\s)\d{1,2}-\d{3}[_a-zA-Zа-яА-Я]?(?=\s|$)""")
private val LESSON_TYPE_REGEX = Regex(LessonType.entries.joinToString(separator = "|") { Regex.escape(it.label) }.let { """(?<=^|\s)($it)(?=\s|$)""" } )
private val SUBGROUP_REGEX = Regex("""\d{2}\sподгруппа""", RegexOption.IGNORE_CASE)
fun parse(pdfBytes: ByteArray): List<Day> {
Log.d(TAG, "Parsing ${pdfBytes.size} bytes")
val doc = try {
@@ -145,7 +141,7 @@ object PdfScheduleParser {
// Поиск кабинета в строке, ибо он последний, если их несколько - то скорее всего разделение на подгруппы
var found = 0
var currentStart = 0
for (roomMatch in ROOM_REGEX.findAll(content)) {
for (roomMatch in TextPatterns.ROOM_REGEX.findAll(content)) {
found++
val raw = content.substring(currentStart, roomMatch.range.last + 1)
@@ -155,7 +151,7 @@ object PdfScheduleParser {
val room = roomMatch.value
val typeMatch = LESSON_TYPE_REGEX.findAll(raw).toList()
val typeMatch = TextPatterns.LESSON_TYPE_REGEX.findAll(raw).toList()
if(typeMatch.isEmpty()) {
lessons.add(Lesson(
raw=raw,
@@ -170,7 +166,7 @@ object PdfScheduleParser {
Log.d(TAG, "$subject $group")
if(group != null && (subject.startsWith("$group,") || subject.startsWith("$group "))) {
subject = subject.substring(group.length + 1).trim()
val subgroupMatch = SUBGROUP_REGEX.find(subject)
val subgroupMatch = TextPatterns.SUBGROUP_REGEX.find(subject)
if(subgroupMatch != null) {
subgroup = subgroupMatch.value.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 setSelectedDayDate(date: String) = dao.setSelectedDayDate(date)
override suspend fun selectedTeacherId(): String? = dao.selectedTeacherId()
override suspend fun setSelectedTeacherId(id: String) = dao.setSelectedTeacherId(id)
override suspend fun selectedTeacherWeekStart(): String? = dao.selectedTeacherWeekStart()
override suspend fun setSelectedTeacherWeekStart(weekStart: String) =
dao.setSelectedTeacherWeekStart(weekStart)
override suspend fun selectedTeacherDayDate(): String? = dao.selectedTeacherDayDate()
override suspend fun setSelectedTeacherDayDate(date: String) =
dao.setSelectedTeacherDayDate(date)
override suspend fun clearSelectedTeacher() = dao.clearSelectedTeacher()
override suspend fun autoUpdateEnabled(): Boolean = dao.autoUpdateEnabled() == "true"
override suspend fun setAutoUpdateEnabled(enabled: Boolean) = dao.setAutoUpdateEnabled(enabled.toString())
@@ -0,0 +1,86 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DepartmentEntity
import agzam.vyatsu.data.local.DepartmentPeriodEntity
import agzam.vyatsu.data.local.TeacherDao
import agzam.vyatsu.data.remote.TeachersParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.Department
import agzam.vyatsu.domain.repository.TeacherRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
class TeacherRepositoryImpl(
private val teacherDao: TeacherDao,
) : TeacherRepository {
override fun observeDepartments(): Flow<List<Department>> =
teacherDao.observeDepartments().map { list -> list.map { it.toDomain() } }
override suspend fun getDepartments(): List<Department> =
teacherDao.observeDepartments().first().map { it.toDomain() }
override suspend fun getFaculties(): List<String> = teacherDao.getFaculties()
override suspend fun refreshIfStale(): Boolean {
val last = teacherDao.maxSyncedAt()
if (last != null && now() - last < STALE_WINDOW_MS) {
return true
}
refresh()
return true
}
override suspend fun forceRefresh(): Boolean {
refresh()
return true
}
private suspend fun refresh() {
val parsed = withContext(Dispatchers.IO) {
VyatsuHttpClient.openStream(VyatsuUrls.TEACHER_PAGE).use(TeachersParser::parseStream)
}
val now = now()
teacherDao.insertAll(
parsed.departments.map { department ->
DepartmentEntity(
departmentId = department.departmentId,
name = department.name,
faculty = department.faculty,
lastSyncedAt = now
)
}
)
val periods = parsed.departments.flatMap { department ->
parsed.dateRanges[department.departmentId].orEmpty().flatMap { (season, ranges) ->
ranges.map { range ->
DepartmentPeriodEntity(
departmentId = department.departmentId,
season = season,
weekStart = ScheduleDates.asDate(range.first).toEpochDay(),
weekEnd = ScheduleDates.asDate(range.second).toEpochDay()
)
}
}
}
teacherDao.deleteAllPeriods()
teacherDao.insertPeriods(periods)
}
private fun now(): Long = System.currentTimeMillis()
private fun DepartmentEntity.toDomain(): Department = Department(
departmentId = departmentId,
name = name,
faculty = faculty
)
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
}
}
@@ -0,0 +1,294 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DepartmentPeriodEntity
import agzam.vyatsu.data.local.TeacherDao
import agzam.vyatsu.data.local.TeacherDayEntity
import agzam.vyatsu.data.local.TeacherLessonEntity
import agzam.vyatsu.data.local.TeacherScheduleDao
import agzam.vyatsu.data.local.TeacherScheduleEntity
import agzam.vyatsu.data.remote.TeacherScheduleParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.model.BulkSyncProgress
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Teacher
import agzam.vyatsu.domain.model.TeacherSchedule
import agzam.vyatsu.domain.model.TimeSlot
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalTime
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
class TeacherScheduleRepositoryImpl(
private val scheduleDao: TeacherScheduleDao,
private val teacherDao: TeacherDao
) : TeacherScheduleRepository {
private val syncMutex = Mutex()
override fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> =
combine(
scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()),
scheduleDao.observeLessonsForPeriod(period.season.name, period.weekStart.toEpochDay())
) { days, lessons ->
buildTeacherSchedule(period, days, lessons)
}
override fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>> =
scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay())
.map { names -> names.map { name -> Teacher(name = name) } }
override suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress> = flow {
// Unique index (departmentId, season, weekStart) breaks on interleaved
// delete+insert from concurrent syncPeriod() callers.
syncMutex.withLock {
val targets = withContext(Dispatchers.IO) {
teacherDao.getPeriodsForDate(date.toEpochDay())
}
if (targets.isEmpty()) {
emit(BulkSyncProgress(0, 0, 0))
return@withLock
}
targets.map { it.season to it.weekStart }
.distinct()
.forEach { (season, weekStart) ->
withContext(Dispatchers.IO) {
scheduleDao.deletePeriod(season.name, weekStart)
}
}
val total = targets.size
val semaphore = Semaphore(PARALLELISM)
val jobs = coroutineScope {
targets.map { target ->
async {
semaphore.withPermit {
try {
withContext(Dispatchers.IO) { syncDepartment(target) }
true
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
Log.e(
TAG,
"sync: failed for department ${target.departmentId} " +
"season ${target.season} week ${target.weekStart} " +
"(${e::class.simpleName}: ${e.message})",
e
)
false
}
}
}
}
}
var done = 0
var failed = 0
for (job in jobs) {
if (job.await()) done++ else failed++
emit(BulkSyncProgress(total, done + failed, failed))
}
withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) }
}
}
override suspend fun hasData(period: SchedulePeriod): Boolean =
withContext(Dispatchers.IO) {
scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay())
}
override suspend fun weeksFor(date: LocalDate): List<SchedulePeriod> =
withContext(Dispatchers.IO) {
teacherDao.getPeriodsForDate(date.toEpochDay())
.distinctBy { it.season to (it.weekStart to it.weekEnd) }
.flatMap { window ->
val windowStart = LocalDate.ofEpochDay(window.weekStart)
val windowEnd = LocalDate.ofEpochDay(window.weekEnd)
generateSequence(
windowStart.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
) { it.plusWeeks(1) }
.takeWhile { it <= windowEnd }
.map { monday ->
val weekEnd = monday.plusDays(6)
SchedulePeriod(
weekStart = monday,
weekEnd = if (weekEnd.isAfter(windowEnd)) windowEnd else weekEnd,
season = window.season
)
}
}
.distinctBy { it.season to it.weekStart }
.sortedBy { it.weekStart }
}
override suspend fun clearAll() = withContext(Dispatchers.IO) {
scheduleDao.deleteAll()
}
private suspend fun syncDepartment(target: DepartmentPeriodEntity) {
val season = target.season
val range = DateRange(
LocalDate.ofEpochDay(target.weekStart).format(RANGE_FMT),
LocalDate.ofEpochDay(target.weekEnd).format(RANGE_FMT)
)
val url = VyatsuUrls.teacherPrepodUrl(target.departmentId, season, range)
Log.d(TAG, "syncDepartment: dept=${target.departmentId} url=$url")
val html = VyatsuHttpClient.getBytes(url).toString(Charsets.UTF_8)
val parsed = TeacherScheduleParser.parse(html)
Log.d(TAG, "syncDepartment: dept=${target.departmentId} parsedDays=${parsed.days.size}")
parsed.days.forEach { day ->
day.intervals.forEach { slot ->
val mel = slot.lessons.filter { it.teacher == "Мельцов В.Ю." }
if (mel.isNotEmpty()) {
Log.d(TAG, "syncDepartment: dept=${target.departmentId} date=${day.date} slot=${slot.start}-${slot.end} melLessons=${mel.size}")
}
}
}
val dayEntities = parsed.days.map {
TeacherDayEntity(scheduleId = 0, date = it.date.toEpochDay())
}
val lessonEntities = parsed.days.flatMap { day ->
day.intervals.flatMapIndexed { slotIndex, slot ->
slot.lessons.mapIndexedNotNull { lessonIndex, lesson ->
val teacher = lesson.teacher ?: return@mapIndexedNotNull null
TeacherLessonEntity(
dayId = 0,
date = day.date.toEpochDay(),
slotIndex = slotIndex,
start = slot.start.toMinutesOfDay(),
end = slot.end.toMinutesOfDay(),
lessonIndex = lessonIndex,
raw = lesson.raw,
room = lesson.room,
groupName = lesson.group,
subgroup = lesson.subgroup,
subject = lesson.subject,
type = lesson.type,
teacher = teacher
)
}
}
}
Log.d(TAG, "syncDepartment: dept=${target.departmentId} -> lessons=${lessonEntities.size}")
val melLessonEntities = lessonEntities.filter { it.teacher == "Мельцов В.Ю." }
melLessonEntities.forEach { le ->
Log.d(
TAG,
"syncDepartment: dept=${target.departmentId} MEL date=${le.date} slotIndex=${le.slotIndex} " +
"start=$le.start end=$le.end raw=${le.raw}"
)
}
scheduleDao.replaceSchedule(
TeacherScheduleEntity(
departmentId = target.departmentId,
season = season,
weekStart = target.weekStart,
weekEnd = target.weekEnd,
lastSyncedAt = now()
),
dayEntities,
lessonEntities
)
}
private suspend fun trimToRecent(keepDistinctPeriods: Int) {
val schedules = scheduleDao.getSchedules()
schedules
.map { it.season to it.weekStart }
.distinct()
.sortedByDescending { it.second }
.drop(keepDistinctPeriods)
.forEach { (season, weekStart) ->
scheduleDao.deletePeriod(season.name, weekStart)
}
}
private fun buildTeacherSchedule(
period: SchedulePeriod,
dayEntities: List<TeacherDayEntity>,
lessonEntities: List<TeacherLessonEntity>
): TeacherSchedule? {
val weekDays = dayEntities
.filter {
it.date >= period.weekStart.toEpochDay() && it.date <= period.weekEnd.toEpochDay()
}
if (weekDays.isEmpty()) return null
val lessonsByDate = lessonEntities.groupBy { it.date }
val days = weekDays
.sortedBy { it.date }
.distinctBy { it.date }
.map { day ->
val lessons = lessonsByDate[day.date].orEmpty()
Log.d(
TAG,
"build: date=${day.date} totalLessons=${lessons.size} " +
"groupedByStart=${lessons.groupBy { it.start }.keys.sorted()}"
)
val slots = lessons.groupBy { it.start }
.toSortedMap()
.map { (startMin, slotLessons) ->
val first = slotLessons.first()
val times = slotLessons.map { "${it.start}-${it.end}" }.distinct()
val teachers = slotLessons.map { it.teacher }.distinct()
Log.d(
TAG,
"build: date=${day.date} start=$startMin firstEnd=$first.end " +
"times=$times teachers=$teachers"
)
TimeSlot(
start = LocalTime.of(startMin / 60, startMin % 60),
end = LocalTime.of(first.end / 60, first.end % 60),
lessons = slotLessons
.sortedBy { it.lessonIndex }
.map { it.toDomain() }
)
}
Day(date = LocalDate.ofEpochDay(day.date), intervals = slots)
}
return TeacherSchedule(period = period, days = days)
}
private fun TeacherLessonEntity.toDomain(): Lesson = Lesson(
raw = raw,
room = room,
group = groupName,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun now(): Long = System.currentTimeMillis()
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
private const val PARALLELISM = 2
private const val TAG = "TeacherScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
}
}
@@ -6,9 +6,13 @@ import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.repository.GroupRepositoryImpl
import agzam.vyatsu.data.repository.PreferencesRepositoryImpl
import agzam.vyatsu.data.repository.ScheduleRepositoryImpl
import agzam.vyatsu.data.repository.TeacherRepositoryImpl
import agzam.vyatsu.data.repository.TeacherScheduleRepositoryImpl
import agzam.vyatsu.domain.repository.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.ScheduleRepository
import agzam.vyatsu.domain.repository.TeacherRepository
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
import agzam.vyatsu.update.AppUpdater
import android.content.Context
import androidx.room.Room
@@ -19,6 +23,7 @@ class AppContainer(context: Context) {
AppDatabase::class.java,
"vyatsu_schedule.db"
)
.addMigrations(AppDatabase.MIGRATION_3_4)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -39,6 +44,17 @@ class AppContainer(context: Context) {
PreferencesRepositoryImpl(database.preferencesDao())
}
val teacherRepository: TeacherRepository by lazy {
TeacherRepositoryImpl(teacherDao = database.teacherDao())
}
val teacherScheduleRepository: TeacherScheduleRepository by lazy {
TeacherScheduleRepositoryImpl(
scheduleDao = database.teacherScheduleDao(),
teacherDao = database.teacherDao()
)
}
val appUpdater: AppUpdater by lazy {
AppUpdater(context.applicationContext, preferencesRepository)
}
@@ -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
)
@@ -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 setSelectedDayDate(date: String)
suspend fun selectedTeacherId(): String? = null
suspend fun setSelectedTeacherId(id: String) = Unit
suspend fun selectedTeacherWeekStart(): String? = null
suspend fun setSelectedTeacherWeekStart(weekStart: String) = Unit
suspend fun selectedTeacherDayDate(): String? = null
suspend fun setSelectedTeacherDayDate(date: String) = Unit
suspend fun clearSelectedTeacher() = Unit
suspend fun autoUpdateEnabled(): Boolean
suspend fun setAutoUpdateEnabled(enabled: Boolean)
@@ -0,0 +1,12 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.Department
import kotlinx.coroutines.flow.Flow
interface TeacherRepository {
fun observeDepartments(): Flow<List<Department>>
suspend fun getDepartments(): List<Department>
suspend fun getFaculties(): List<String>
suspend fun refreshIfStale(): Boolean
suspend fun forceRefresh(): Boolean
}
@@ -0,0 +1,17 @@
package agzam.vyatsu.domain.repository
import agzam.vyatsu.domain.model.BulkSyncProgress
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Teacher
import agzam.vyatsu.domain.model.TeacherSchedule
import kotlinx.coroutines.flow.Flow
import java.time.LocalDate
interface TeacherScheduleRepository {
fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>>
fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?>
suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress>
suspend fun hasData(period: SchedulePeriod): Boolean
suspend fun weeksFor(date: LocalDate): List<SchedulePeriod>
suspend fun clearAll()
}
@@ -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.settings.SettingsScreen
import agzam.vyatsu.ui.settings.UpdateViewModel
import agzam.vyatsu.ui.teacher.TeacherScheduleScreen
import agzam.vyatsu.ui.teacher.TeacherScheduleViewModel
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@@ -19,6 +21,7 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
@@ -35,10 +38,13 @@ private enum class HomeTab(val label: String) {
@Composable
fun HomeScreen(
scheduleViewModel: ScheduleViewModel,
teacherScheduleViewModel: TeacherScheduleViewModel,
updateViewModel: UpdateViewModel,
onSwitchGroup: () -> Unit
onSwitchGroup: () -> Unit,
onSwitchTeacher: () -> Unit
) {
var tab by rememberSaveable { mutableStateOf(HomeTab.SCHEDULE) }
val teacherState by teacherScheduleViewModel.uiState.collectAsState()
Scaffold(
bottomBar = {
@@ -70,7 +76,14 @@ fun HomeScreen(
.padding(innerPadding)
) {
when (tab) {
HomeTab.SCHEDULE -> ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
HomeTab.SCHEDULE -> if (teacherState.teacherName != null) {
TeacherScheduleScreen(
viewModel = teacherScheduleViewModel,
onSwitchTeacher = onSwitchTeacher
)
} else {
ScheduleScreen(viewModel = scheduleViewModel, onSwitchGroup = onSwitchGroup)
}
HomeTab.CALLS -> CallsScreen()
HomeTab.BUILDINGS -> BuildingsScreen()
HomeTab.SETTINGS -> SettingsScreen(viewModel = updateViewModel)
@@ -54,17 +54,5 @@ fun BuildingsScreen() {
}
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
}
item {
Spacer(Modifier.height(24.dp))
Text(
"Версия ${BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
}
}
}
@@ -0,0 +1,336 @@
package agzam.vyatsu.ui.picker
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.Teacher
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PickerScreen(
viewModel: PickerViewModel,
onGroupPicked: (Group) -> Unit,
onTeacherPicked: (Teacher) -> Unit
) {
val state by viewModel.uiState.collectAsState()
var segment by rememberSaveable { mutableStateOf(PickerSegment.GROUPS) }
LaunchedEffect(segment) { viewModel.selectSegment(segment) }
Scaffold { innerPadding ->
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
.padding(innerPadding)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
SegmentedButton(
selected = segment == PickerSegment.GROUPS,
onClick = { segment = PickerSegment.GROUPS },
shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2)
) {
Text("Группы")
}
SegmentedButton(
selected = segment == PickerSegment.TEACHERS,
onClick = { segment = PickerSegment.TEACHERS },
shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2)
) {
Text("Преподаватели")
}
}
Spacer(Modifier.height(16.dp))
when (segment) {
PickerSegment.GROUPS -> GroupSection(
state = state,
onSearch = viewModel::searchGroup,
onRetry = viewModel::retryGroups,
onRefresh = viewModel::refreshGroups,
onGroupPicked = onGroupPicked
)
PickerSegment.TEACHERS -> TeacherSection(
state = state,
onSearch = viewModel::searchTeacher,
onRetry = viewModel::retryTeachers,
onTeacherPicked = onTeacherPicked
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun GroupSection(
state: PickerUiState,
onSearch: (String) -> Unit,
onRetry: () -> Unit,
onRefresh: () -> Unit,
onGroupPicked: (Group) -> Unit
) {
var isSearchActive by remember { mutableStateOf(false) }
if (!isSearchActive) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Выберите группу",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
}
}
if (state.groupsLoading && state.groups.isEmpty()) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
return
}
if (state.error != null && state.groups.isEmpty()) {
Column(Modifier.fillMaxWidth()) {
Text(
state.error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = onRetry) { Text("Повторить") }
}
return
}
SearchBar(
modifier = Modifier.fillMaxWidth(),
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
inputField = {
SearchBarDefaults.InputField(
query = state.groupQuery,
onQueryChange = onSearch,
onSearch = { isSearchActive = false },
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
placeholder = { Text("Поиск группы") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (isSearchActive && state.groupQuery.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Clear, contentDescription = "Очистить")
}
}
}
)
}
) {
GroupList(
groups = state.filteredGroups,
onPick = { group ->
isSearchActive = false
onGroupPicked(group)
}
)
}
if (!isSearchActive) {
Spacer(modifier = Modifier.height(8.dp))
GroupList(state.filteredGroups, onGroupPicked)
}
}
@Composable
private fun GroupList(groups: List<Group>, onPick: (Group) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(groups) { group ->
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(group) }) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
Text(group.name, style = MaterialTheme.typography.bodyMedium)
}
}
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TeacherSection(
state: PickerUiState,
onSearch: (String) -> Unit,
onRetry: () -> Unit,
onTeacherPicked: (Teacher) -> Unit
) {
var isSearchActive by remember { mutableStateOf(false) }
val progress = state.syncProgress
val syncing = progress != null && progress.done < progress.total
if (!isSearchActive) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Выберите преподавателя",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onRetry) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
}
}
if (syncing) {
Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
LinearProgressIndicator(
progress = { progress.done / progress.total.toFloat() },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(4.dp))
Text(
text = "Кафедры: ${progress.done}/${progress.total}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (state.teachersLoading && state.teachers.isEmpty() && !syncing) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
return
}
if (state.error != null && state.teachers.isEmpty() && !syncing) {
Column(Modifier.fillMaxWidth()) {
Text(
state.error,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
TextButton(onClick = onRetry) { Text("Повторить") }
}
return
}
SearchBar(
modifier = Modifier.fillMaxWidth(),
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
inputField = {
SearchBarDefaults.InputField(
query = state.teacherQuery,
onQueryChange = onSearch,
onSearch = { isSearchActive = false },
expanded = isSearchActive,
onExpandedChange = { isSearchActive = it },
placeholder = { Text("Поиск преподавателя") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (isSearchActive && state.teacherQuery.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(Icons.Default.Clear, contentDescription = "Очистить")
}
}
}
)
}
) {
TeacherList(
teachers = state.filteredTeachers,
onPick = { teacher ->
isSearchActive = false
onTeacherPicked(teacher)
}
)
}
if (!isSearchActive) {
Spacer(modifier = Modifier.height(8.dp))
when {
state.teachers.isEmpty() -> Text(
text = "Кафедры без расписания на эту неделю",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
else -> TeacherList(state.filteredTeachers, onTeacherPicked)
}
}
}
@Composable
private fun TeacherList(teachers: List<Teacher>, onPick: (Teacher) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(teachers) { teacher ->
Column(modifier = Modifier.fillMaxWidth().clickable { onPick(teacher) }) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
Text(teacher.name, style = MaterialTheme.typography.bodyMedium)
}
}
HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
}
}
}
@@ -0,0 +1,3 @@
package agzam.vyatsu.ui.picker
enum class PickerSegment { GROUPS, TEACHERS }
@@ -0,0 +1,196 @@
package agzam.vyatsu.ui.picker
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.BulkSyncProgress
import agzam.vyatsu.domain.model.Group
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Teacher
import agzam.vyatsu.domain.repository.GroupRepository
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.TeacherRepository
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.time.DayOfWeek
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class PickerUiState(
val segment: PickerSegment = PickerSegment.GROUPS,
val groups: List<Group> = emptyList(),
val groupQuery: String = "",
val filteredGroups: List<Group> = emptyList(),
val groupsLoading: Boolean = true,
val teachers: List<Teacher> = emptyList(),
val teacherQuery: String = "",
val filteredTeachers: List<Teacher> = emptyList(),
val teachersLoading: Boolean = true,
val syncProgress: BulkSyncProgress? = null,
val error: String? = null
)
class PickerViewModel(
private val groupRepository: GroupRepository,
private val teacherRepository: TeacherRepository,
private val teacherScheduleRepository: TeacherScheduleRepository,
private val preferencesRepository: PreferencesRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(PickerUiState())
val uiState: StateFlow<PickerUiState> = _uiState.asStateFlow()
private var teachersJob: kotlinx.coroutines.Job? = null
private var syncJob: kotlinx.coroutines.Job? = null
init {
groupsLoad()
teachersLoad()
}
fun selectSegment(segment: PickerSegment) {
_uiState.value = _uiState.value.copy(segment = segment)
}
private fun groupsLoad() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
groupRepository.refreshIfStale()
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.message ?: "Не удалось загрузить группы"
)
}
}
groupRepository.observeGroups().collect { groups ->
val query = _uiState.value.groupQuery
_uiState.value = _uiState.value.copy(
groupsLoading = false,
groups = groups,
filteredGroups = filterGroups(groups, query),
error = null
)
}
}
}
fun searchGroup(query: String) {
val groups = _uiState.value.groups
_uiState.value = _uiState.value.copy(
groupQuery = query,
filteredGroups = filterGroups(groups, query)
)
}
private fun filterGroups(groups: List<Group>, query: String): List<Group> {
if (query.isBlank()) return groups
val lowerQuery = query.lowercase()
return groups.filter { group ->
group.name.lowercase().contains(lowerQuery) || group.faculty.lowercase().contains(lowerQuery)
}
}
fun retryGroups() {
_uiState.value = _uiState.value.copy(groupsLoading = true, error = null)
groupsLoad()
}
fun refreshGroups() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
groupRepository.forceRefresh()
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.message ?: "Не удалось обновить список групп"
)
}
}
}
}
private fun teachersLoad() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
try {
teacherRepository.refreshIfStale()
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
error = e.message ?: "Не удалось загрузить кафедры"
)
}
}
syncAndObserveTeachers()
}
}
private fun syncAndObserveTeachers() {
val today = ScheduleDates.moscowToday()
val weekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val period = SchedulePeriod(
weekStart = weekStart,
weekEnd = weekStart.plusDays(6),
season = ScheduleDates.currentSeason(today)
)
_uiState.value = _uiState.value.copy(syncProgress = null, teachersLoading = true)
syncJob?.cancel()
syncJob = viewModelScope.launch {
try {
teacherScheduleRepository.syncPeriod(today).collect { progress ->
_uiState.value = _uiState.value.copy(syncProgress = progress)
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
_uiState.value = _uiState.value.copy(
error = e.message ?: "Не удалось загрузить расписание преподавателей"
)
} finally {
_uiState.value = _uiState.value.copy(teachersLoading = false)
}
}
teachersJob?.cancel()
teachersJob = viewModelScope.launch {
teacherScheduleRepository.observeTeachers(period)
.debounce(300)
.collect { teachers ->
val query = _uiState.value.teacherQuery
val isLoading = _uiState.value.teachersLoading
_uiState.value = _uiState.value.copy(
teachers = teachers,
filteredTeachers = filterTeachers(teachers, query),
teachersLoading = isLoading && teachers.isEmpty(),
error = null
)
}
}
}
fun searchTeacher(query: String) {
val teachers = _uiState.value.teachers
_uiState.value = _uiState.value.copy(
teacherQuery = query,
filteredTeachers = filterTeachers(teachers, query)
)
}
private fun filterTeachers(teachers: List<Teacher>, query: String): List<Teacher> {
if (query.isBlank()) return teachers
val lowerQuery = query.lowercase()
return teachers.filter { teacher -> teacher.name.lowercase().contains(lowerQuery) }
}
fun retryTeachers() {
syncAndObserveTeachers()
}
}
@@ -1,13 +1,11 @@
package agzam.vyatsu.ui.schedule
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TimeSlot
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
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
@@ -16,55 +14,31 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.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.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.DateTimeFormatter
val hPadding = 16.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -128,6 +102,32 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
val schedule = state.schedule!!
val weekDays = state.weekStart
?.let { ws -> schedule.periods.firstOrNull { it.weekStart == ws } }
?.let { schedule.daysOf(it) }
.orEmpty()
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
val pagerState = rememberPagerState(
initialPage = selectedDayIndex,
pageCount = { weekDays.size }
)
LaunchedEffect(state.dayDate) {
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
if (targetPage != -1 && targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage) // Мгновенное переключение без анимации
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (pagerState.isScrollInProgress && page in weekDays.indices) {
viewModel.selectDay(weekDays[page].date)
}
}
}
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
@@ -153,20 +153,31 @@ fun ScheduleScreen(viewModel: ScheduleViewModel, onSwitchGroup: () -> Unit) {
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
onSelect = viewModel::selectDay
onSelect = viewModel::selectDay,
pagerState = pagerState
)
HorizontalDivider(Modifier.padding(top=0.dp, start = hPadding, end = hPadding))
if (day.intervals.all { it.lessons.isEmpty() }) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(day.intervals) { slotIndex, slot ->
LessonSlot(slotIndex = slotIndex, slot = slot)
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
val day = weekDays.getOrNull(page)
if (day == null || day.intervals.all { it.lessons.isEmpty() }) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(day.intervals) { slotIndex, slot ->
LessonSlot(slotIndex = slotIndex, slot = slot)
}
}
}
}
@@ -176,234 +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)
}
)
}
}
}
}
@Composable
private fun DayChips(
days: List<Day>,
selectedDate: LocalDate?,
todayDate: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date
FilterChip(
selected = selected,
onClick = { onSelect(day.date) },
label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
dayName(day.date.dayOfWeek),
style = MaterialTheme.typography.labelMedium
)
Text(
day.date.format(CHIP_DATE),
style = MaterialTheme.typography.labelSmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
},
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayChip && !selected) {
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
)
} else {
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
},
shape = RoundedCornerShape(8.dp)
)
}
}
}
private fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@Composable
private fun LessonSlot(slotIndex: Int, slot: TimeSlot) {
val context = LocalContext.current
var indexColor = 0
Column(Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
HorizontalDivider(
modifier = Modifier.width(10.dp + hPadding).padding(start = hPadding),
color = MaterialTheme.colorScheme.primary
)
Text(
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 4.dp)
)
HorizontalDivider(color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f).padding(end = hPadding))
}
if (slot.lessons.isEmpty()) {
Text("—", style = MaterialTheme.typography.bodyMedium)
} else {
slot.lessons.forEachIndexed { index, lesson ->
var baseSurface = Modifier.fillMaxWidth().padding(vertical = 2.dp)
if ((indexColor) % 2 != 0) {
baseSurface = baseSurface.background(
MaterialTheme.colorScheme.surfaceContainer
);
}
indexColor++
// XXX:
// Убрать SelectionContainer?
// Режим редактирования расписания?
// Меню на onClick?
// Меню на onLongClick?
SelectionContainer() {
Column(
baseSurface
// XXX: combinedClickable?
//.combinedClickable(
// onClick = {
// Toast.makeText(
// context,
// lesson.raw,
// Toast.LENGTH_SHORT
// ).show()
// },
// onLongClick = {},
// // TODO: onLongClickLabel = "(Accessibility)"
//),
) {
var any = false
val meta = listOfNotNull(
if (lesson.type == LessonType.OTHER) {
null
} else {
lesson.type.label
},
lesson.subgroup,
lesson.teacher,
lesson.room
).filter { it.isNotBlank() }.joinToString(" · ")
if (meta.isNotEmpty()) {
Text(
meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = hPadding)
)
any = true
}
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if (subject.isNotEmpty()) {
Text(
subject,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = hPadding)
)
any = true
}
if (!any) {
Text(
"пусто",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = hPadding)
)
}
}
}
}
}
}
}
private fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
DayOfWeek.MONDAY -> "Пн"
DayOfWeek.TUESDAY -> "Вт"
DayOfWeek.WEDNESDAY -> "Ср"
DayOfWeek.THURSDAY -> "Чт"
DayOfWeek.FRIDAY -> "Пт"
DayOfWeek.SATURDAY -> "Сб"
DayOfWeek.SUNDAY -> "Вс"
}
private val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
private val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
private val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
@@ -0,0 +1,133 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.Day
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.collectLatest
import java.time.LocalDate
import kotlin.math.max
import kotlin.math.min
internal fun getVisiblePercentage(item: LazyListItemInfo, layoutInfo: LazyListLayoutInfo): Float {
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
val itemStart = item.offset
val itemEnd = item.offset + item.size
val visibleStart = max(itemStart, viewportStart)
val visibleEnd = min(itemEnd, viewportEnd)
val visibleSize = visibleEnd - visibleStart
return if (visibleSize <= 0) 0f else visibleSize.toFloat() / item.size
}
@Composable
internal fun DayChips(
days: List<Day>,
selectedDate: LocalDate?,
todayDate: LocalDate?,
pagerState: PagerState,
onSelect: (LocalDate) -> Unit
) {
val listState = rememberLazyListState()
val density = LocalDensity.current
val paddingPx = with(density) { hPadding.toPx() }
LaunchedEffect(pagerState, days) {
snapshotFlow {
pagerState.currentPage
}.collectLatest { targetPage ->
if (targetPage in days.indices) {
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
val targetItem = visibleItems.firstOrNull { it.index == targetPage }
if (targetItem != null) {
val visiblePercentage = getVisiblePercentage(targetItem, layoutInfo)
val minVisibleThreshold = 0.9f
if (visiblePercentage < minVisibleThreshold) {
val viewPortWidth = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val halfViewport = viewPortWidth / 2f
val centerOffset = (halfViewport - (targetItem.size / 2f) - paddingPx).toInt()
listState.animateScrollToItem(index = targetPage, scrollOffset = -centerOffset)
}
} else {
val viewPortWidth = layoutInfo.viewportEndOffset - layoutInfo.viewportStartOffset
val halfViewport = viewPortWidth / 2f
val averageSize = visibleItems.map { it.size }.average().toInt().coerceAtLeast(150)
val centerOffset = (halfViewport - (averageSize / 2f) - paddingPx).toInt()
listState.animateScrollToItem(index = targetPage, scrollOffset = -centerOffset)
}
}
}
}
LazyRow(state = listState, horizontalArrangement = Arrangement.spacedBy(6.dp), contentPadding = PaddingValues(horizontal = hPadding, vertical = 2.dp),) {
items(days, key = { it.date.toEpochDay() }) { day ->
val selected = selectedDate == day.date
val isTodayChip = todayDate == day.date
FilterChip(
selected = selected,
onClick = { onSelect(day.date) },
label = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
dayName(day.date.dayOfWeek),
style = MaterialTheme.typography.labelMedium
)
Text(
day.date.format(CHIP_DATE),
style = MaterialTheme.typography.labelSmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
},
modifier = Modifier.padding(vertical = 2.dp),
border = if (isTodayChip && !selected) {
FilterChipDefaults.filterChipBorder(
borderColor = MaterialTheme.colorScheme.primary,
borderWidth = 1.dp,
enabled = true,
selected = false
)
} else {
FilterChipDefaults.filterChipBorder(enabled = true, selected = selected)
},
shape = RoundedCornerShape(8.dp)
)
}
}
}
@@ -0,0 +1,118 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.LessonType
import agzam.vyatsu.domain.model.TimeSlot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@Composable
internal fun LessonSlot(slotIndex: Int, slot: TimeSlot, showTeacher: Boolean = true) {
val context = LocalContext.current
var indexColor = 0
Column(Modifier.fillMaxWidth()) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
HorizontalDivider(
modifier = Modifier.width(10.dp + hPadding).padding(start = hPadding),
color = MaterialTheme.colorScheme.primary
)
Text(
"${slotIndex + 1} пара · ${slot.start.format(TIME)}–${slot.end.format(TIME)}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 4.dp)
)
HorizontalDivider(color = MaterialTheme.colorScheme.primary, modifier = Modifier.weight(1f).padding(end = hPadding))
}
if (slot.lessons.isEmpty()) {
Text("—", style = MaterialTheme.typography.bodyMedium)
} else {
slot.lessons.forEachIndexed { index, lesson ->
var baseSurface = Modifier.fillMaxWidth().padding(vertical = 2.dp)
if ((indexColor) % 2 != 0) {
baseSurface = baseSurface.background(
MaterialTheme.colorScheme.surfaceContainer
);
}
indexColor++
// XXX:
// Убрать SelectionContainer?
// Режим редактирования расписания?
// Меню на onClick?
// Меню на onLongClick?
SelectionContainer() {
Column(
baseSurface
// XXX: combinedClickable?
//.combinedClickable(
// onClick = {
// Toast.makeText(
// context,
// lesson.raw,
// Toast.LENGTH_SHORT
// ).show()
// },
// onLongClick = {},
// // TODO: onLongClickLabel = "(Accessibility)"
//),
) {
var any = false
val meta = listOfNotNull(
if (lesson.type == LessonType.OTHER) {
null
} else {
lesson.type.label
},
lesson.subgroup,
if (showTeacher) lesson.teacher else lesson.group,
lesson.room
).filter { it.isNotBlank() }.joinToString(" · ")
if (meta.isNotEmpty()) {
Text(
meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = hPadding)
)
any = true
}
val subject = lesson.subject?.takeIf { it.isNotBlank() } ?: lesson.raw
if (subject.isNotEmpty()) {
Text(
subject,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = hPadding)
)
any = true
}
if (!any) {
Text(
"пусто",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = hPadding)
)
}
}
}
}
}
}
}
@@ -0,0 +1,21 @@
package agzam.vyatsu.ui.schedule.components
import androidx.compose.ui.unit.dp
import java.time.DayOfWeek
import java.time.format.DateTimeFormatter
internal val hPadding = 16.dp
internal fun dayName(dayOfWeek: DayOfWeek): String = when (dayOfWeek) {
DayOfWeek.MONDAY -> "Пн"
DayOfWeek.TUESDAY -> "Вт"
DayOfWeek.WEDNESDAY -> "Ср"
DayOfWeek.THURSDAY -> "Чт"
DayOfWeek.FRIDAY -> "Пт"
DayOfWeek.SATURDAY -> "Сб"
DayOfWeek.SUNDAY -> "Вс"
}
internal val FULL_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
internal val CHIP_DATE: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM")
internal val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
@@ -0,0 +1,96 @@
package agzam.vyatsu.ui.schedule.components
import agzam.vyatsu.domain.model.SchedulePeriod
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun WeekSelector(
periods: List<SchedulePeriod>,
selectedWeekStart: LocalDate?,
todayWeekStart: LocalDate?,
onSelect: (LocalDate) -> Unit
) {
val selected = periods.firstOrNull { it.weekStart == selectedWeekStart }
var expanded by remember { mutableStateOf(false) }
Box {
Row(
modifier = Modifier
.clip(MaterialTheme.shapes.small)
.clickable(enabled = true, onClick = { expanded = true })
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
selected?.let(::weekLabel) ?: "Период",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.widthIn(min = 220.dp)
) {
periods.forEach { period ->
val isTodayWeek = todayWeekStart == period.weekStart
DropdownMenuItem(
text = {
Text(
weekLabel(period),
style = MaterialTheme.typography.bodyLarge
)
},
trailingIcon = if (isTodayWeek) {
{
Text(
"сегодня",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
} else {
null
},
onClick = {
expanded = false
onSelect(period.weekStart)
}
)
}
}
}
}
internal fun weekLabel(period: SchedulePeriod): String {
val first = period.weekStart
return "с ${first.format(CHIP_DATE)}"
}
@@ -0,0 +1,188 @@
package agzam.vyatsu.ui.teacher
import agzam.vyatsu.ui.schedule.components.DayChips
import agzam.vyatsu.ui.schedule.components.FULL_DATE
import agzam.vyatsu.ui.schedule.components.LessonSlot
import agzam.vyatsu.ui.schedule.components.WeekSelector
import agzam.vyatsu.ui.schedule.components.dayName
import agzam.vyatsu.ui.schedule.components.hPadding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TeacherScheduleScreen(viewModel: TeacherScheduleViewModel, onSwitchTeacher: () -> Unit) {
val state by viewModel.uiState.collectAsState()
Column(Modifier.fillMaxSize()) {
TopAppBar(
title = { Text(state.teacherName ?: "Расписание преподавателя", style = MaterialTheme.typography.titleMedium) },
actions = {
if (state.isLoading && state.schedule != null) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
TextButton(onClick = { onSwitchTeacher() }) {
Text("Сменить преподавателя")
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить")
}
},
windowInsets = WindowInsets(0.dp)
)
Column(Modifier.fillMaxSize().padding(horizontal = 0.dp, vertical = 0.dp)) {
when {
state.schedule == null && state.loadError -> {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Не удалось загрузить расписание",
style = MaterialTheme.typography.bodyLarge
)
TextButton(onClick = { viewModel.refresh() }) {
Text("Повторить")
}
}
}
state.schedule == null -> {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
}
else -> {
val schedule = state.schedule!!
val weekDays = schedule.days
val day = weekDays.firstOrNull { it.date == state.dayDate }
if (day == null) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
} else {
val selectedDayIndex = weekDays.indexOfFirst { it.date == state.dayDate }.coerceAtLeast(0)
val pagerState = rememberPagerState(
initialPage = selectedDayIndex,
pageCount = { weekDays.size }
)
LaunchedEffect(state.dayDate) {
val targetPage = weekDays.indexOfFirst { it.date == state.dayDate }
if (targetPage != -1 && targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage)
}
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
if (pagerState.isScrollInProgress && page in weekDays.indices) {
viewModel.selectDay(weekDays[page].date)
}
}
}
val isTodayDay = day.date == state.todayDate
val dayLabel = if (isTodayDay) "Сегодня " else ""
Row(
Modifier.fillMaxWidth().padding(horizontal = hPadding),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"$dayLabel${dayName(day.date.dayOfWeek)} · ${day.date.format(FULL_DATE)}",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f)
)
WeekSelector(
periods = state.weeks.ifEmpty { listOf(schedule.period) },
selectedWeekStart = state.weekStart,
todayWeekStart = state.todayDate
?.let { todayDate ->
state.weeks.firstOrNull { period ->
!todayDate.isBefore(period.weekStart) && !todayDate.isAfter(period.weekEnd)
}?.weekStart
}
?: schedule.period.weekStart.takeIf {
state.todayDate?.let { today -> !today.isBefore(it) && !today.isAfter(it.plusDays(6)) } == true
},
onSelect = viewModel::selectWeek
)
}
HorizontalDivider(Modifier.padding(top = 8.dp, bottom = 0.dp, start = hPadding, end = hPadding))
DayChips(
days = weekDays,
selectedDate = state.dayDate,
todayDate = state.todayDate,
onSelect = viewModel::selectDay,
pagerState = pagerState
)
HorizontalDivider(Modifier.padding(top = 0.dp, start = hPadding, end = hPadding))
HorizontalPager(state = pagerState, modifier = Modifier.weight(1f)) { page ->
val pagerDay = weekDays.getOrNull(page)
if (pagerDay == null || pagerDay.intervals.all { it.lessons.isEmpty() }) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Занятий нет", style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp)
) {
itemsIndexed(pagerDay.intervals) { slotIndex, slot ->
LessonSlot(slotIndex = slotIndex, slot = slot, showTeacher = false)
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,241 @@
package agzam.vyatsu.ui.teacher
import agzam.vyatsu.domain.ScheduleDates
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.TeacherSchedule
import agzam.vyatsu.domain.repository.PreferencesRepository
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.temporal.TemporalAdjusters
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class TeacherScheduleViewModel(
private val scheduleRepository: TeacherScheduleRepository,
private val preferencesRepository: PreferencesRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(TeacherScheduleUiState())
val uiState: StateFlow<TeacherScheduleUiState> = _uiState.asStateFlow()
private var currentTeacherName: String? = null
private var currentPeriod: SchedulePeriod? = null
private var scheduleJob: Job? = null
private var weeksJob: Job? = null
private var loadRevision = 0
init {
viewModelScope.launch {
val teacherId = preferencesRepository.selectedTeacherId()
if (teacherId != null) {
selectTeacher(teacherId)
} else {
_uiState.value = _uiState.value.copy(noTeacher = true, teacherName = null)
}
}
}
fun selectTeacher(name: String) {
val revision = ++loadRevision
currentTeacherName = name
_uiState.value = _uiState.value.copy(noTeacher = false, teacherName = name, isLoading = true, loadError = false)
viewModelScope.launch {
try {
preferencesRepository.setSelectedTeacherId(name)
val savedWeekStart = runCatching {
preferencesRepository.selectedTeacherWeekStart()?.let(LocalDate::parse)
}.getOrNull()
val savedDayDate = runCatching {
preferencesRepository.selectedTeacherDayDate()?.let(LocalDate::parse)
}.getOrNull()
val today = ScheduleDates.moscowToday()
val weekStart = savedWeekStart
?: today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(
noTeacher = false,
teacherName = name,
weekStart = weekStart,
dayDate = savedDayDate
)
val period = SchedulePeriod(
weekStart = weekStart,
weekEnd = weekStart.plusDays(6),
season = ScheduleDates.currentSeason(today)
)
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
}
}
private suspend fun loadSchedule(period: SchedulePeriod, revision: Int) {
if (revision != loadRevision) return
scheduleJob?.cancel()
scheduleJob = null
currentPeriod = period
if (!scheduleRepository.hasData(period)) {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
try {
scheduleRepository.syncPeriod(period.weekStart).collect { }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
}
if (revision != loadRevision) return
if (!scheduleRepository.hasData(period)) {
_uiState.value = _uiState.value.copy(isLoading = false, loadError = true)
return
}
}
weeksJob?.cancel()
weeksJob = viewModelScope.launch {
val weeks = scheduleRepository.weeksFor(period.weekStart)
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(weeks = weeks)
}
scheduleJob = viewModelScope.launch {
scheduleRepository.observeSchedule(period).collect { schedule ->
if (revision != loadRevision) {
throw CancellationException("stale schedule observation")
}
currentTeacherName?.let { applySchedule(schedule, it) }
}
}
}
private suspend fun applySchedule(schedule: TeacherSchedule?, teacherName: String) {
val current = _uiState.value
if (schedule == null) return
val filteredDays = schedule.daysForTeacher(teacherName)
android.util.Log.d("TeacherSchedDebug", "apply: teacher=$teacherName periodWeek=$schedule.period rawDays=${schedule.days.size} filteredDays=${filteredDays.size}")
filteredDays.forEach { d ->
android.util.Log.d(
"TeacherSchedDebug",
"apply: filteredDay=${d.date} slots=${d.intervals.map { "${it.start}-${it.end}:${it.lessons.size}" }}"
)
}
val today = ScheduleDates.moscowToday()
val todayDate = if (isInPeriod(today, schedule.period)) today else null
val dayDate = when {
filteredDays.any { it.date == current.dayDate } -> current.dayDate
filteredDays.any { it.date == todayDate } -> todayDate
else -> filteredDays.firstOrNull()?.date
}
preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString())
dayDate?.let { preferencesRepository.setSelectedTeacherDayDate(it.toString()) }
_uiState.value = current.copy(
schedule = schedule.copy(days = filteredDays),
weekStart = schedule.period.weekStart,
dayDate = dayDate,
todayDate = todayDate,
isLoading = false,
loadError = false
)
}
fun selectWeek(weekStart: LocalDate) {
val state = _uiState.value
val name = state.teacherName ?: return
if (state.weekStart == weekStart) return
val revision = ++loadRevision
viewModelScope.launch {
try {
preferencesRepository.setSelectedTeacherWeekStart(weekStart.toString())
if (revision != loadRevision) return@launch
_uiState.value = _uiState.value.copy(
weekStart = weekStart,
dayDate = null,
schedule = null,
isLoading = true,
loadError = false
)
val period = SchedulePeriod(
weekStart = weekStart,
weekEnd = weekStart.plusDays(6),
season = ScheduleDates.currentSeason(weekStart)
)
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
}
}
fun selectDay(date: LocalDate) {
val state = _uiState.value
val schedule = state.schedule ?: return
if (date.isBefore(schedule.period.weekStart) || date.isAfter(schedule.period.weekEnd)) return
viewModelScope.launch {
preferencesRepository.setSelectedTeacherWeekStart(schedule.period.weekStart.toString())
preferencesRepository.setSelectedTeacherDayDate(date.toString())
_uiState.value = _uiState.value.copy(weekStart = schedule.period.weekStart, dayDate = date)
}
}
fun refresh() {
val period = currentPeriod ?: return
val revision = ++loadRevision
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true, loadError = false)
try {
scheduleRepository.syncPeriod(ScheduleDates.moscowToday()).collect { }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
}
if (revision != loadRevision) return@launch
loadSchedule(period, revision)
} catch (e: CancellationException) {
throw e
}
}
}
fun switchTeacher() {
loadRevision++
viewModelScope.launch {
currentTeacherName = null
currentPeriod = null
scheduleJob?.cancel()
scheduleJob = null
preferencesRepository.clearSelectedTeacher()
_uiState.value = TeacherScheduleUiState(noTeacher = true)
}
}
private fun isInPeriod(date: LocalDate, period: SchedulePeriod): Boolean =
!date.isBefore(period.weekStart) && !date.isAfter(period.weekEnd)
}
data class TeacherScheduleUiState(
val noTeacher: Boolean = true,
val isLoading: Boolean = false,
val loadError: Boolean = false,
val schedule: TeacherSchedule? = null,
val teacherName: String? = null,
val weekStart: LocalDate? = null,
val dayDate: LocalDate? = null,
val todayDate: LocalDate? = null,
val weeks: List<SchedulePeriod> = emptyList()
)
@@ -118,7 +118,7 @@ class AppUpdater(
}
val remoteVersion = release.tagName?.trimStart('v', 'V')
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME)) {
if (remoteVersion == null || !isNewerVersion(remoteVersion, BuildConfig.VERSION_NAME.trimStart('v', 'V'))) {
_state.value = UpdateState.UpToDate
return null
}
@@ -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,86 @@
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)
@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)
}
}
@@ -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>
+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>