Files
psu-schedule/ios/PSUSchedule/Views/HomeView.swift
Zakhar Timoshenko f8984e72d9 feat: implement changes tracking for university documents
- Added a new data structure `ScheduleChanges` to represent changes when a university document is republished.
- Implemented methods to read and write changes to a local JSON file.
- Enhanced the `Repository` class to detect changes between the current and fresh timetable versions.
- Updated the `MainViewModel` to expose changes to the UI and handle marking changes as seen.
- Created UI components for displaying changes in both Home and Settings screens.
- Added support for changes in iOS views, including a new `ChangesCard` and `ChangesSheet`.
- Implemented logic to compare timetable versions and identify added, removed, and changed lessons.
2026-10-19 23:57:10 +03:00

580 lines
27 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// The overview: this week at a glance, the session countdown, today's lessons,
/// attendance, tasks and the next study day. On iPad the week grid sits beside it.
struct HomeView: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var details: LessonRef?
@State private var editor: EditorRequest?
@State private var session = false
@State private var changes: ScheduleChanges?
var body: some View {
NavigationStack {
content
.background(Color(.systemGroupedBackground))
.navigationTitle("Обзор")
.toolbar {
ToolbarItem(placement: .topBarTrailing) { GroupButton() }
ToolbarSpacer(.fixed, placement: .topBarTrailing)
ToolbarItem(placement: .topBarTrailing) { SettingsButton() }
}
.refreshable { await model.refresh() }
.navigationDestination(for: HomeRoute.self) { _ in SubjectsView() }
}
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
.sheet(item: $editor) { NoteEditor(request: $0) }
.sheet(isPresented: $session) { SessionSheet(exams: model.upcomingExams).presentationDetents([.medium, .large]) }
.sheet(item: $changes) { ChangesSheet(record: $0).presentationDetents([.medium, .large]) }
}
@ViewBuilder private var content: some View {
if let t = model.timetable {
if t.semester == nil {
VStack(spacing: 16) {
Banner(text: t.note ?? "Даты для этого расписания не рассчитаны.", offline: false).padding(.horizontal)
EmptyState(
title: "Обзор недоступен",
text: "Строки Word по дням недели — во вкладке «Расписание».",
symbol: "doc.text",
action: ("Открыть", { router.tab = .schedule })
)
}
} else if sizeClass == .regular {
// iPad: the overview beside this week's grid, as School Planner pairs them.
HStack(alignment: .top, spacing: 16) {
ScrollView { overview(t) }.frame(width: 420)
WeekPane(timetable: t) { details = $0 }
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 28, style: .continuous))
.padding(.trailing, 16)
.padding(.bottom, 16)
}
} else {
ScrollView { overview(t).readableWidth() }
}
} else if model.status.loading {
ProgressView().controlSize(.large).frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
VStack {
StatusBanner().padding(.horizontal)
EmptyState(
title: "Расписание не загружено",
text: "Выберите группу — обзор дня и недели появится здесь.",
symbol: "person.2",
action: model.settings.current != nil
? ("Повторить", { Task { await model.refresh() } })
: ("Выбрать группу", { router.groups = true })
)
}
}
}
private func overview(_ t: Timetable) -> some View {
let today = model.today
let todaySlots = model.slots(t, on: today)
let next = (1...7).lazy.map { today.adding(days: $0) }.map { ($0, model.slots(t, on: $0)) }.first { !$0.1.isEmpty }
return VStack(alignment: .leading, spacing: 16) {
StatusBanner()
// The university republished the document: what changed for the group, until seen.
if let record = model.changes, !record.seen {
ChangesCard(record: record, onShow: {
changes = record
model.markChangesSeen(record)
}, onClose: {
withAnimation { model.markChangesSeen(record) }
})
.transition(.opacity.combined(with: .scale(scale: 0.96)))
}
WeekReport(timetable: t)
SessionCard(timetable: t) { session = true }
DayTitle(label: "Сегодня", date: today)
TodayCard(slots: todaySlots, onOpen: { details = LessonRef(lesson: $0, date: today) }, onDay: { router.openDay(today) })
AttendanceCard(slots: todaySlots)
NavigationLink(value: HomeRoute.subjects) { SubjectsLink(timetable: t) }
.buttonStyle(.plain)
TasksCard(onEdit: { editor = EditorRequest(editing: $0) }, onAll: { router.tab = .plans })
if let next {
let (day, slots) = next
DayTitle(label: day == today.adding(days: 1) ? "Завтра" : day.weekdayName, date: day)
SectionCard(symbol: "calendar.badge.checkmark", title: pairs(slots.count)) {
ForEach(slots) { slot in
ForEach(slot.lessons, id: \.key) { lesson in
Button { details = LessonRef(lesson: lesson, date: day) } label: { CompactLesson(lesson: lesson, slot: slot) }
.buttonStyle(.plain)
}
}
DayButton { router.openDay(day) }
}
}
}
.padding(16)
.animation(.smooth, value: model.notes)
.animation(.smooth, value: model.marks)
}
}
/// Screens the overview opens in its own stack.
enum HomeRoute: Hashable {
case subjects
}
/// The way to the subjects: how many there are and how busy this week is.
private struct SubjectsLink: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var body: some View {
let subjects = Set((timetable.days.flatMap(\.lessons) + timetable.weekly.flatMap(\.lessons)).map { subjectKey($0.subject) }).count
let monday = model.today.monday
let week = (0..<7).reduce(0) { $0 + model.slots(timetable, on: monday.adding(days: $1)).count }
HStack(spacing: 14) {
Image(systemName: "books.vertical")
.font(.body.weight(.semibold))
.foregroundStyle(Color.onBrand)
.frame(width: 40, height: 40)
.background(Color.brand, in: .circle)
VStack(alignment: .leading, spacing: 2) {
Text("Предметы").font(.headline)
Text("\(subjects) \(plural(subjects, "предмет", "предмета", "предметов")) · на этой неделе \(pairs(week))")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
}
.card(padding: 16)
.contentShape(.rect)
}
}
private struct DayTitle: View {
var label: String
var date: CivilDate
var body: some View {
HStack(alignment: .lastTextBaseline) {
Text(label).font(.title2.bold()).accessibilityAddTraits(.isHeader)
Spacer()
Text(date.longLabel).font(.subheadline).foregroundStyle(.secondary)
}
.padding(.top, 8)
}
}
private struct DayButton: View {
var action: () -> Void
var body: some View {
Button(action: action) { Label("Открыть день", systemImage: "list.bullet.below.rectangle") }
.font(.subheadline.weight(.semibold))
}
}
/**
* The current week at a glance: how many lessons are still ahead, a chip per day with its count
* (past days ticked off, today highlighted) and the homework due this week. Once the week is
* over it looks ahead to the next one.
*/
private struct WeekReport: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var body: some View {
let t = timetable
let date = model.today
let clock = model.clock
let monday = date.monday
let days = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(t, on: $0)) }
.filter { $0.0.weekday < 7 || !$0.1.isEmpty }
let total = days.reduce(0) { $0 + $1.1.count }
let left = days.reduce(0) { sum, day in
sum + day.1.filter { $0.date > date || ($0.date == date && $0.end > clock) }.count
}
let week = t.day(monday)?.week
let due = model.notes.filter { n in
guard let d = CivilDate(n.date) else { return false }
return n.task && !n.done && d >= date && d <= monday.adding(days: 6)
}.count
SectionCard(symbol: "calendar", title: week.map { "Неделя \($0.n)" } ?? "Эта неделя") {
VStack(alignment: .leading, spacing: 2) {
Text(headline(total: total, left: left)).font(.title3.bold())
HStack(spacing: 12) {
Text(weekSpan(monday, monday.adding(days: 6))).font(.subheadline).foregroundStyle(.secondary)
if let week { WeekMark(week: week) }
}
}
if total > 0 {
HStack(spacing: 6) {
ForEach(days, id: \.0) { day in DayChip(date: day.0, count: day.1.count, today: date) }
}
}
if due > 0 {
Meta(symbol: "checklist", text: "Сдать до воскресенья: \(due) \(plural(due, "задание", "задания", "заданий"))")
}
if left == 0 {
let next = monday.adding(days: 7)
let nextTotal = (0..<7).reduce(0) { $0 + model.slots(t, on: next.adding(days: $1)).count }
if nextTotal > 0 {
let w = t.day(next)?.week
Meta(symbol: "calendar.badge.checkmark", text: "Дальше " + (w.map { "неделя \($0.n), \($0.white ? "белая" : "зелёная"): " } ?? "") + pairs(nextTotal))
}
}
}
}
private func headline(total: Int, left: Int) -> String {
if total == 0 { return "На этой неделе пар нет" }
if left == 0 { return "Пары этой недели закончились" }
if left == total { return "Впереди " + pairs(total) }
return "Осталось \(left) \(plural(left, "пара", "пары", "пар")) из \(total)"
}
}
/// A weekday with its number of lessons: ticked off once past, strong today.
private struct DayChip: View {
var date: CivilDate
var count: Int
var today: CivilDate
var body: some View {
let past = date < today
let isToday = date == today
VStack(spacing: 4) {
Text(date.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary)
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(count == 0 ? Color.clear : isToday ? Color.brand : past ? Color(.tertiarySystemFill) : Color.brand.opacity(0.15))
Group {
if count == 0 { Text("—") }
else if past { Image(systemName: "checkmark").font(.footnote.weight(.bold)) }
else { Text("\(count)").font(.subheadline.weight(.bold)) }
}
.foregroundStyle(isToday ? Color.onBrand : past || count == 0 ? .secondary : Color.brand)
}
.frame(height: 36)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(date.weekdayName): " + (count == 0 ? "пар нет" : pairs(count) + (past ? ", прошли" : "")))
}
}
/// The next lessons of today: the first in full with a countdown, the rest compact.
private struct TodayCard: View {
@Environment(AppModel.self) private var model
var slots: [Slot]
var onOpen: (Lesson) -> Void
var onDay: () -> Void
var body: some View {
let clock = model.clock
let left = slots.filter { $0.end > clock }
SectionCard(symbol: "clock", title: "Следующие занятия") {
if slots.isEmpty {
Quiet(title: "Сегодня занятий нет", text: "Отдохните — ближайшие пары ниже.")
} else if left.isEmpty {
Quiet(title: "Пары на сегодня закончились", text: "Сегодня было \(pairs(slots.count)).")
} else {
let first = left[0]
ForEach(first.lessons, id: \.key) { lesson in
Button { onOpen(lesson) } label: { FeaturedLesson(lesson: lesson, slot: first, clock: clock) }
.buttonStyle(.plain)
}
if left.count > 1 { Divider() }
ForEach(left.dropFirst()) { slot in
ForEach(slot.lessons, id: \.key) { lesson in
Button { onOpen(lesson) } label: { CompactLesson(lesson: lesson, slot: slot) }.buttonStyle(.plain)
}
}
}
if !slots.isEmpty { DayButton(action: onDay) }
}
}
}
private struct Quiet: View {
var title: String
var text: String
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline)
Text(text).font(.subheadline).foregroundStyle(.secondary)
}
}
}
/// The next lesson in full: subject, time with a countdown, room and teacher.
private struct FeaturedLesson: View {
@Environment(\.display) private var show
var lesson: Lesson
var slot: Slot
var clock: ClockTime
var body: some View {
let running = clock >= slot.start
let minutes = (running ? slot.end : slot.start).minutes - clock.minutes
HStack(alignment: .top, spacing: 14) {
SubjectDot(subject: lesson.subject, size: 14).padding(.top, 6)
VStack(alignment: .leading, spacing: 6) {
if let session = lesson.session { SessionBadge(kind: session) }
Text(lesson.subject).font(.title3.bold()).lineLimit(3).foregroundStyle(.primary)
HStack(spacing: 10) {
Text(slot.timeLabel).font(.body.monospacedDigit()).foregroundStyle(.secondary)
Text(running ? "Идёт · ещё \(minutesLabel(minutes))" : "Через \(minutesLabel(minutes))")
.font(.footnote.weight(.semibold))
.padding(.horizontal, 10).padding(.vertical, 4)
.foregroundStyle(running ? Color.onBrand : Color.brand)
.background(running ? Color.brand : Color.brand.opacity(0.14), in: .capsule)
}
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
else if show.rooms && !lesson.rooms.isEmpty { Meta(symbol: "mappin.and.ellipse", text: "Ауд. " + lesson.rooms.joined(separator: ", ")) }
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
}
Spacer(minLength: 0)
}
.contentShape(.rect)
}
}
/// One-tap attendance for today's begun lessons without a mark; leaves once all are marked.
private struct AttendanceCard: View {
@Environment(AppModel.self) private var model
var slots: [Slot]
var body: some View {
let date = model.today
let open = slots.filter { $0.start <= model.clock }
.flatMap { slot in slot.lessons.map { (slot, $0) } }
.filter { model.marks.forLesson($0.1.subject, date, $0.1.start) == nil }
if model.groupKey != nil && !open.isEmpty {
SectionCard(symbol: "person.badge.clock", title: "Отметьте посещаемость") {
ForEach(open, id: \.1.key) { item in
let (slot, lesson) = item
HStack(spacing: 12) {
SubjectDot(subject: lesson.subject, size: 10)
VStack(alignment: .leading, spacing: 2) {
Text(lesson.subject).font(.subheadline.weight(.semibold)).lineLimit(2)
Text(slot.timeLabel).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary)
}
Spacer()
GlassEffectContainer(spacing: 6) {
HStack(spacing: 6) {
Button { model.mark(lesson, on: date, present: true) } label: { Image(systemName: "checkmark") }
.buttonStyle(.glass)
.tint(.green)
.accessibilityLabel("Был на паре «\(lesson.subject)»")
Button { model.mark(lesson, on: date, present: false) } label: { Image(systemName: "xmark") }
.buttonStyle(.glass)
.tint(.red)
.accessibilityLabel("Пропустил пару «\(lesson.subject)»")
}
}
}
}
}
}
}
}
/**
* Homework and notes ahead: open tasks (overdue ones flagged) and notes for today and later,
* soonest first. Ticked tasks stay struck through until their day has passed.
*/
private struct TasksCard: View {
@Environment(AppModel.self) private var model
var onEdit: (Note) -> Void
var onAll: () -> Void
@State private var expanded = false
var body: some View {
let date = model.today
let pending = model.notes
.filter { (CivilDate($0.date) ?? date) >= date || ($0.task && !$0.done) }
.sorted { a, b in
let ka = (a.task && a.done ? 1 : 0, a.date, a.start)
let kb = (b.task && b.done ? 1 : 0, b.date, b.start)
return ka < kb
}
if !pending.isEmpty {
SectionCard(symbol: "checklist", title: "Задания и заметки", trailing: "\(pending.open.count)") {
ForEach(expanded ? pending : Array(pending.prefix(4))) { note in
NoteRow(note: note) { onEdit(note) }
}
HStack(spacing: 20) {
if pending.count > 4 {
Button(expanded ? "Свернуть" : "Показать все (\(pending.count))") { withAnimation(.smooth) { expanded.toggle() } }
}
Button("Все планы", action: onAll)
}
.font(.subheadline.weight(.semibold))
}
}
}
}
// MARK: - Session
/**
* The session countdown: days to the next exam or credit, with the subject, date and room.
* Before the university publishes the session it counts down to the end of the semester.
*/
private struct SessionCard: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var onOpen: () -> Void
var body: some View {
let today = model.today
let exams = model.upcomingExams
let next = exams.first(where: \.counts) ?? exams.first
let semesterEnd = timetable.semester.flatMap { CivilDate($0.end) }.flatMap { $0 >= today ? $0 : nil }
if let target = next.flatMap({ CivilDate($0.date) }) ?? semesterEnd {
let days = today.days(to: target)
let card = HStack(spacing: 18) {
Countdown(days: days)
VStack(alignment: .leading, spacing: 2) {
Text(next.map { "Сессия · \($0.type.lowercased()) \(countdownLabel(days))" } ?? "До конца семестра \(countdownLabel(days).replacingOccurrences(of: "через ", with: ""))")
.font(.subheadline.weight(.semibold)).foregroundStyle(Color.brand)
if let next {
Text(next.subject).font(.headline).lineLimit(2)
Text(examWhen(next)).font(.subheadline).foregroundStyle(.secondary)
if exams.count > 1 { Text("Ещё \(exams.count - 1) в сессии").font(.footnote).foregroundStyle(.secondary).padding(.top, 4) }
} else {
Text(timetable.semester?.label ?? "Семестр").font(.headline)
Text("Учёба до \(semesterEnd!.longLabel). Даты экзаменов появятся здесь, когда университет опубликует расписание сессии.")
.font(.subheadline).foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.foregroundStyle(.primary)
.card()
// Only a published session opens a sheet; the semester countdown is just information.
if next != nil {
Button(action: onOpen) { card }.buttonStyle(.plain)
} else {
card
}
}
}
}
/// The big number in a glass circle, tinted when the day is close.
private struct Countdown: View {
var days: Int
var body: some View {
let urgent = days <= 3
VStack(spacing: 0) {
Text(days == 0 ? "!" : "\(days)").font(.title.bold()).monospacedDigit()
Text(days == 0 ? "сегодня" : days == 1 ? "день" : plural(days, "день", "дня", "дней")).font(.caption.weight(.medium))
}
.foregroundStyle(urgent ? Color.orange : Color.brand)
.frame(width: 84, height: 84)
.glassEffect(.regular.tint((urgent ? Color.orange : Color.brand).opacity(0.18)), in: .circle)
.accessibilityHidden(true)
}
}
func countdownLabel(_ days: Int) -> String {
switch days {
case 0: "сегодня"
case 1: "завтра"
default: "через \(days) \(plural(days, "день", "дня", "дней"))"
}
}
/// "Пн, 12 января, 09:00 · ауд. 213А".
func examWhen(_ e: Exam) -> String {
guard let d = CivilDate(e.date) else { return e.date }
return d.shortWeekday + ", " + d.longLabel + ", " + e.start + (e.rooms.isEmpty ? "" : " · ауд. " + e.rooms.joined(separator: ", "))
}
/// The whole session in date order, consultations included.
struct SessionSheet: View {
@Environment(\.dismiss) private var dismiss
var exams: [Exam]
var body: some View {
NavigationStack {
List {
let counted = exams.filter(\.counts).count
Section {
ForEach(exams, id: \.self) { e in
let d = CivilDate(e.date) ?? .today()
HStack(spacing: 16) {
VStack {
Text("\(d.day)").font(.title2.bold())
Text(d.shortWeekday).font(.caption).foregroundStyle(.secondary)
}
.frame(width: 44)
VStack(alignment: .leading, spacing: 2) {
Text(e.type + " · " + countdownLabel(CivilDate.today().days(to: d)))
.font(.subheadline.weight(.semibold))
.foregroundStyle(e.counts ? Color.brand : .secondary)
Text(e.subject).font(.headline)
Text([e.start + (e.end.map { "–\($0)" } ?? ""), e.rooms.isEmpty ? nil : "ауд. " + e.rooms.joined(separator: ", "), e.teachers.isEmpty ? nil : e.teachers.joined(separator: ", ")].compactMap { $0 }.joined(separator: " · "))
.font(.subheadline).foregroundStyle(.secondary)
}
}
}
} header: {
Text("\(counted) \(plural(counted, "экзамен или зачёт", "экзамена или зачёта", "экзаменов и зачётов"))" + (exams.count > counted ? ", консультаций: \(exams.count - counted)" : ""))
} footer: {
Text("Даты собраны из всех документов каталога по пометкам «экз.», «зач.» и «конс.». Сверяйтесь с документом Word.")
}
}
.navigationTitle("Сессия")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
}
}
}
// MARK: - iPad week pane
/// This week's grid beside the overview; on Sunday it already shows the coming week.
private struct WeekPane: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var onOpen: (LessonRef) -> Void
@State private var monday: CivilDate?
var body: some View {
let first = (model.today.weekday == 7 ? model.today.adding(days: 1) : model.today).monday
let shown = monday ?? first
let loaded = Set(timetable.dates)
let week = timetable.day(shown)?.week
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(week.map { "Неделя \($0.n)" } ?? "Неделя").font(.headline)
HStack(spacing: 10) {
Text(weekSpan(shown, shown.adding(days: 6))).font(.subheadline).foregroundStyle(.secondary)
if let week { WeekMark(week: week, short: true) }
}
}
Spacer()
GlassEffectContainer(spacing: 8) {
HStack(spacing: 8) {
if shown != first {
Button("Эта неделя", systemImage: "calendar.badge.clock") { withAnimation(.smooth) { monday = first } }
.labelStyle(.iconOnly)
}
Button("Предыдущая неделя", systemImage: "chevron.left") { withAnimation(.smooth) { monday = shown.adding(days: -7) } }
.labelStyle(.iconOnly)
.disabled(!loaded.contains(shown.adding(days: -7)))
Button("Следующая неделя", systemImage: "chevron.right") { withAnimation(.smooth) { monday = shown.adding(days: 7) } }
.labelStyle(.iconOnly)
.disabled(!loaded.contains(shown.adding(days: 7)))
}
.buttonStyle(.glass)
}
}
.padding(20)
WeekGrid(timetable: timetable, monday: shown, onOpen: onOpen)
.id(shown)
.transition(.push(from: .trailing))
}
}
}