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 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]) } } @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() 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)) } } }