diff --git a/app/src/main/java/agzam/vyatsu/domain/model/BulkSyncProgress.kt b/app/src/main/java/agzam/vyatsu/domain/model/BulkSyncProgress.kt new file mode 100644 index 0000000..3ec0d06 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/domain/model/BulkSyncProgress.kt @@ -0,0 +1,7 @@ +package agzam.vyatsu.domain.model + +data class BulkSyncProgress( + val total: Int, + val done: Int, + val failed: Int +) \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/domain/model/Department.kt b/app/src/main/java/agzam/vyatsu/domain/model/Department.kt new file mode 100644 index 0000000..28f9198 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/domain/model/Department.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/domain/model/ParsedDepartments.kt b/app/src/main/java/agzam/vyatsu/domain/model/ParsedDepartments.kt new file mode 100644 index 0000000..b93e462 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/domain/model/ParsedDepartments.kt @@ -0,0 +1,6 @@ +package agzam.vyatsu.domain.model + +data class ParsedDepartments( + val departments: List, + val dateRanges: Map>> +) \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/domain/model/Teacher.kt b/app/src/main/java/agzam/vyatsu/domain/model/Teacher.kt new file mode 100644 index 0000000..020c663 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/domain/model/Teacher.kt @@ -0,0 +1,11 @@ +package agzam.vyatsu.domain.model + +/** + * Преподаватель ВятГУ. + * Один и тот же преподаватель может вести занятия в нескольких кафедрах, + * поэтому модель не привязана к departmentId — расписание агрегируется + * по всем кафедрам периода. + */ +data class Teacher( + val name: String +) \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/domain/model/TeacherSchedule.kt b/app/src/main/java/agzam/vyatsu/domain/model/TeacherSchedule.kt new file mode 100644 index 0000000..03a9f13 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/domain/model/TeacherSchedule.kt @@ -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] и [TimeSlot] сохраняется. + * + * Дедупликация — ТОЛЬКО по полному совпадению (дата + слот + аудитория + + * группа + предмет + тип + raw): два занятия коллапсируют в одно лишь когда + * они идентичны целиком (одно физическое занятие, прочитанное из файлов двух + * кафедр). Всё, что отличается хотя бы одним полем, остаётся и показывается. + */ + fun daysForTeacher(teacherName: String): List = + days.mapNotNull { day -> + val seen = HashSet() + 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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt b/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt index 412b004..1c71b79 100644 --- a/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt +++ b/app/src/main/java/agzam/vyatsu/domain/repository/PreferencesRepository.kt @@ -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) diff --git a/app/src/main/java/agzam/vyatsu/domain/repository/TeacherRepository.kt b/app/src/main/java/agzam/vyatsu/domain/repository/TeacherRepository.kt new file mode 100644 index 0000000..3733c0e --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/domain/repository/TeacherRepository.kt @@ -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> + suspend fun getDepartments(): List + suspend fun getFaculties(): List + suspend fun refreshIfStale(): Boolean + suspend fun forceRefresh(): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/agzam/vyatsu/domain/repository/TeacherScheduleRepository.kt b/app/src/main/java/agzam/vyatsu/domain/repository/TeacherScheduleRepository.kt new file mode 100644 index 0000000..a685c76 --- /dev/null +++ b/app/src/main/java/agzam/vyatsu/domain/repository/TeacherScheduleRepository.kt @@ -0,0 +1,16 @@ +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> + fun observeSchedule(period: SchedulePeriod): Flow + suspend fun syncPeriod(date: LocalDate): Flow + suspend fun hasData(period: SchedulePeriod): Boolean + suspend fun clearAll() +} \ No newline at end of file diff --git a/app/src/test/java/agzam/vyatsu/TeacherScheduleDomainTest.kt b/app/src/test/java/agzam/vyatsu/TeacherScheduleDomainTest.kt new file mode 100644 index 0000000..1d80db6 --- /dev/null +++ b/app/src/test/java/agzam/vyatsu/TeacherScheduleDomainTest.kt @@ -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 + ) +} \ No newline at end of file