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,229 @@
import Charts
import SwiftUI
/// Everything the loaded weeks say about one subject.
private struct SubjectSummary: Identifiable {
var name: String
var types: [String]
var teachers: [String]
var rooms: [String]
var upcoming: [(Slot, Lesson)]
var weekDone: Int
var weekTotal: Int
var id: String { subjectKey(name) }
}
private func unique(_ items: [String]) -> [String] {
var seen = Set<String>()
return items.filter { seen.insert($0).inserted }
}
/// Subjects soonest first, with the week's progress, teachers and rooms.
struct SubjectsView: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
@State private var open: String?
var body: some View {
Group {
if let t = model.timetable, t.semester != nil {
let subjects = summaries(t)
ScrollView {
VStack(alignment: .leading, spacing: 16) {
WeekLoad(timetable: t, subjects: subjects.count)
HStack(alignment: .lastTextBaseline) {
Text("Все предметы").font(.title2.bold())
Spacer()
Text("по ближайшему занятию").font(.subheadline).foregroundStyle(.secondary)
}
.padding(.top, 8)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 12, alignment: .top)], spacing: 12) {
ForEach(subjects) { s in
Button { open = s.id } label: { SubjectCard(summary: s) }.buttonStyle(.plain)
}
}
}
.padding(16)
.frame(maxWidth: 1200)
.frame(maxWidth: .infinity)
}
.sheet(item: Binding(get: { open.flatMap { id in subjects.first { $0.id == id } } }, set: { open = $0?.id })) { s in
SubjectDetails(summary: s).presentationDetents([.medium, .large])
}
} else if model.timetable != nil {
EmptyState(title: "Предметы недоступны", text: "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", symbol: "calendar.badge.exclamationmark")
} else {
EmptyState(title: "Предметов пока нет", text: "Выберите группу — предметы появятся из её расписания.", symbol: "books.vertical", action: ("Выбрать группу", { router.groups = true }))
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Предметы")
}
private func summaries(_ t: Timetable) -> [SubjectSummary] {
let date = model.today
let clock = model.clock
let monday = date.monday
let entries = t.dates.flatMap { model.slots(t, on: $0) }.flatMap { slot in slot.lessons.map { (slot, $0) } }
let grouped = Dictionary(grouping: entries) { subjectKey($0.1.subject) }
return grouped.values.map { items in
let lessons = items.map(\.1)
let week = items.filter { $0.0.date.monday == monday }
return SubjectSummary(
name: lessons[0].subject,
types: unique(lessons.map { $0.type.isEmpty ? "Занятие" : $0.type }),
teachers: unique(lessons.flatMap(\.teachers)),
rooms: unique(lessons.flatMap(\.rooms)),
upcoming: items.filter { $0.0.date > date || ($0.0.date == date && $0.0.end > clock) },
weekDone: week.filter { $0.0.date < date || ($0.0.date == date && $0.0.end <= clock) }.count,
weekTotal: week.count
)
}
// Soonest first; subjects with nothing ahead go to the end.
.sorted { a, b in
switch (a.upcoming.first?.0, b.upcoming.first?.0) {
case let (x?, y?): (x.date, x.start, a.name) < (y.date, y.start, b.name)
case (.some, nil): true
case (nil, .some): false
default: a.name < b.name
}
}
}
}
/// Pairs per weekday of the current week as a bar chart, today's bar in the strong tone.
private struct WeekLoad: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var subjects: Int
var body: some View {
let date = model.today
let monday = date.monday
let days = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(timetable, on: $0).count) }
.filter { $0.0.weekday < 7 || $0.1 > 0 }
let total = days.reduce(0) { $0 + $1.1 }
let most = days.map(\.1).max() ?? 0
let busiest = days.filter { $0.1 == most && most > 0 }.map { $0.0.weekdayName.lowercased() }
SectionCard(symbol: "chart.bar", title: "Эта неделя") {
VStack(alignment: .leading, spacing: 2) {
Text("\(pairs(total)) · \(subjects) \(plural(subjects, "предмет", "предмета", "предметов"))").font(.title3.bold())
if !busiest.isEmpty {
Text("Больше всего — \(busiest.joined(separator: " и ")), \(pairs(most))").font(.subheadline).foregroundStyle(.secondary)
}
}
Chart(days, id: \.0) { day in
BarMark(x: .value("День", day.0.shortWeekday), y: .value("Пары", day.1), width: .ratio(0.55))
.foregroundStyle(day.0 == date ? Color.brand : Color.brand.opacity(day.0 < date ? 0.3 : 0.55))
.clipShape(.rect(cornerRadius: 6))
.annotation(position: .top) {
Text(day.1 > 0 ? "\(day.1)" : "—").font(.caption.weight(.semibold)).foregroundStyle(day.0 == date ? Color.brand : .secondary)
}
}
.chartYAxis(.hidden)
.chartYScale(domain: 0...max(1, most + 1))
.frame(height: 120)
.accessibilityLabel(days.map { "\($0.0.weekdayName): \($0.1)" }.joined(separator: ", "))
}
}
}
private struct SubjectCard: View {
@Environment(AppModel.self) private var model
var summary: SubjectSummary
var body: some View {
WithSubjectColor(subject: summary.name) { c in
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Text(abbreviate(summary.name))
.font(.footnote.weight(.bold))
.lineLimit(1)
.minimumScaleFactor(0.6)
.padding(.horizontal, 4)
.foregroundStyle(c.onStrong)
.frame(width: 44, height: 44)
.background(c.strong, in: .rect(cornerRadius: 12, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(summary.name).font(.headline).lineLimit(3)
Text(summary.types.joined(separator: ", ")).font(.subheadline).foregroundStyle(.secondary)
}
}
if let first = summary.upcoming.first {
let (slot, lesson) = first
Meta(symbol: "clock", text: nextLabel(slot) + (lesson.rooms.isEmpty ? "" : " · ауд. " + lesson.rooms.joined(separator: ", ")))
} else {
Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет")
}
if !summary.teachers.isEmpty { Meta(symbol: "person", text: summary.teachers.joined(separator: ", ")) }
if summary.weekTotal > 0 {
ProgressView(value: Double(summary.weekDone), total: Double(summary.weekTotal)) {
Text("На этой неделе: \(summary.weekDone) из \(summary.weekTotal)").font(.subheadline)
}
.tint(c.strong)
}
let stats = model.marks.stats(for: summary.name)
if stats.total > 0 {
Meta(symbol: "person.badge.clock", text: "Посещено \(stats.present) из \(stats.total)")
}
let tasks = model.notes.filter { subjectKey($0.subject) == summary.id && $0.task && !$0.done }.count
if tasks > 0 { Meta(symbol: "checklist", text: "\(tasks) \(plural(tasks, "задание", "задания", "заданий"))") }
}
.foregroundStyle(.primary)
.card(padding: 18)
}
}
private func nextLabel(_ slot: Slot) -> String {
let today = model.today
let day = slot.date == today ? "сегодня" : slot.date == today.adding(days: 1) ? "завтра" : slot.date.shortWeekday.lowercased() + ", " + slot.date.longLabel
return "Далее \(day), \(slot.start)"
}
}
/// A subject in full: teachers, rooms, upcoming lessons, attendance and tasks.
private struct SubjectDetails: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
var summary: SubjectSummary
@State private var details: LessonRef?
@State private var editor: EditorRequest?
var body: some View {
NavigationStack {
List {
Section {
LabeledContent("Виды занятий", value: summary.types.joined(separator: ", "))
if !summary.teachers.isEmpty { LabeledContent("Преподаватели", value: summary.teachers.joined(separator: ", ")) }
if !summary.rooms.isEmpty { LabeledContent("Аудитории", value: summary.rooms.joined(separator: ", ")) }
let stats = model.marks.stats(for: summary.name)
if stats.total > 0 { LabeledContent("Посещаемость", value: "\(stats.present) из \(stats.total)") }
}
let notes = model.notes.filter { subjectKey($0.subject) == summary.id }
if !notes.isEmpty {
Section("Задания и заметки") {
ForEach(notes) { note in NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) } }
}
}
Section("Ближайшие занятия") {
if summary.upcoming.isEmpty { Text("Занятий в этом семестре больше нет").foregroundStyle(.secondary) }
ForEach(Array(summary.upcoming.prefix(12).enumerated()), id: \.offset) { _, item in
Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: {
VStack(alignment: .leading, spacing: 2) {
Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold))
Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", ")].compactMap { $0 }.joined(separator: " · "))
.font(.subheadline).foregroundStyle(.secondary)
}
}
.foregroundStyle(.primary)
}
}
}
.navigationTitle(summary.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
}
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
.sheet(item: $editor) { NoteEditor(request: $0) }
}
}