Add date and time handling, models, schedule management, storage, and widget support

- Introduced `Dates.swift` for handling date and time in the university's timezone.
- Created `Models.swift` to define data structures for timetable, lessons, and directory search results.
- Implemented `Schedule.swift` for managing lesson slots and timetable functionalities.
- Developed `Storage.swift` for user settings and data persistence.
- Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
This commit is contained in:
2026-10-18 20:26:12 +03:00
parent 6b68cec34a
commit 170a44c377
40 changed files with 58862 additions and 51029 deletions

View File

@@ -0,0 +1,566 @@
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) {
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))
}
}
}