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,222 @@
import SwiftUI
/// Whose day to show: a teacher or a room.
enum SearchTarget: Hashable {
case teacher(String)
case room(String)
var title: String {
switch self {
case .teacher(let name): name
case .room(let room): "Аудитория \(room)"
}
}
}
/// "Кто где": teachers and rooms across every timetable of the university, only online.
struct SearchView: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
@State private var hits: DirectorySearch?
@State private var error: String?
@State private var retries = 0
var body: some View {
NavigationStack {
List {
if let error {
Banner(text: error, offline: true, onRetry: { self.error = nil; retries += 1 })
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
if let hits, router.searchQuery.trimmed.count >= 2 {
if !hits.teachers.isEmpty {
Section("Преподаватели") {
ForEach(hits.teachers, id: \.name) { t in
NavigationLink(value: SearchTarget.teacher(t.name)) {
VStack(alignment: .leading, spacing: 2) {
Text(t.name).font(.body.weight(.medium))
Text(([t.faculties.compactMap(facultyShort).joined(separator: ", ")].filter { !$0.isEmpty } + ["\(t.count) \(plural(t.count, "занятие", "занятия", "занятий"))"]).joined(separator: " · "))
.font(.subheadline).foregroundStyle(.secondary)
}
}
}
}
}
if !hits.rooms.isEmpty {
Section("Аудитории") {
ForEach(hits.rooms, id: \.room) { r in
NavigationLink(value: SearchTarget.room(r.room)) {
Label("Аудитория \(r.room)", systemImage: "door.left.hand.open")
}
}
}
}
if hits.teachers.isEmpty && hits.rooms.isEmpty {
EmptyState(title: "Никого не нашли", text: "Ни преподавателя, ни аудитории с таким названием в расписаниях каталога.", symbol: "magnifyingglass")
.listRowBackground(Color.clear)
}
} else if error == nil {
EmptyState(
title: "Кто где",
text: "Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
symbol: "binoculars"
)
.listRowBackground(Color.clear)
}
}
.navigationTitle("Поиск")
.navigationDestination(for: SearchTarget.self) { DirectoryDayView(target: $0) }
}
.task(id: "\(router.searchQuery)|\(retries)") {
let q = router.searchQuery.trimmed
guard q.count >= 2 else { hits = nil; return }
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
do {
hits = try await model.api.searchDirectory(q)
error = nil
} catch is CancellationError {
} catch {
self.error = error.localizedDescription
}
}
}
}
/// A teacher's or room's lessons on a day, with a glass strip of dates.
struct DirectoryDayView: View {
@Environment(AppModel.self) private var model
var target: SearchTarget
@State private var date = CivilDate.today().weekday == 7 ? CivilDate.today().adding(days: 1) : CivilDate.today()
@State private var day: DirectoryDay?
@State private var error: String?
@State private var loading = false
@State private var retries = 0
@State private var bells: [Bell] = []
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
if let error {
Banner(text: error, offline: true, onRetry: { self.error = nil; retries += 1 })
}
if loading && day == nil {
ProgressView().frame(maxWidth: .infinity).padding(.top, 60)
} else if let day {
if day.lessons.isEmpty {
EmptyState(title: "Занятий нет", text: "По расписаниям каталога в этот день здесь пусто.", symbol: "calendar.badge.checkmark")
.padding(.top, 40)
}
ForEach(Array(day.lessons.enumerated()), id: \.offset) { _, l in
DirectoryLessonCard(lesson: l, target: target, bells: bells)
}
Text(coverageNote(day.coverage))
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 8)
}
}
.padding(16)
.readableWidth()
}
.safeAreaBar(edge: .top, spacing: 0) {
DateStrip(selected: $date).padding(.horizontal, 12).padding(.bottom, 6).readableWidth()
}
.background(Color(.systemGroupedBackground))
.navigationTitle(target.title)
.navigationSubtitle(date.weekdayName + ", " + date.longLabel)
.navigationBarTitleDisplayMode(.inline)
.task { if bells.isEmpty { bells = (try? await model.api.calendar().bells) ?? model.timetable?.bells ?? [] } }
.task(id: "\(date.iso)|\(retries)") {
loading = true
defer { loading = false }
do {
day = switch target {
case .teacher(let name): try await model.api.teacherDay(name, date: date)
case .room(let room): try await model.api.roomDay(room, date: date)
}
error = nil
} catch is CancellationError {
} catch {
self.error = error.localizedDescription
}
}
}
private func coverageNote(_ c: Coverage) -> String {
var s = "По расписаниям из Word: учтено \(c.documents) из \(c.total) документов каталога \(c.year)."
if !c.failed.isEmpty { s += " Не распознано: \(c.failed.count)." }
if !c.withoutSemester.isEmpty { s += " Без дат (нет семестра в таблице недель): \(c.withoutSemester.count)." }
return s
}
}
private struct DirectoryLessonCard: View {
var lesson: DirectoryLesson
var target: SearchTarget
var bells: [Bell]
var body: some View {
let pair = bells.first { $0.start == lesson.start }?.pair
WithSubjectColor(subject: lesson.subject) { c in
VStack(alignment: .leading, spacing: 6) {
Text(lesson.subject).font(.headline)
Text(lesson.start + (lesson.end.map { " → \($0)" } ?? "") + (pair.map { " · \($0) пара" } ?? "")).font(.body.monospacedDigit())
FlowLayout(spacing: 14) {
if case .teacher = target {
Meta(symbol: "mappin.and.ellipse", text: lesson.distance ? "Дистанционно" : lesson.rooms.joined(separator: ", ") + (lesson.moved ? " (перенос)" : ""))
} else if !lesson.teachers.isEmpty {
Meta(symbol: "person", text: lesson.teachers.joined(separator: ", "))
}
if !lesson.groups.isEmpty { Meta(symbol: "person.3", text: lesson.groups.joined(separator: ", ")) }
}
FlowLayout(spacing: 6) {
if !lesson.type.isEmpty { Tag(text: lesson.type, foreground: c.onSoft, background: c.onSoft.opacity(0.12)) }
if lesson.uncertain { Tag(text: "Сверьте недели", foreground: .red, background: .red.opacity(0.14)) }
}
}
.foregroundStyle(c.onSoft)
.padding(.horizontal, 18).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(c.soft, in: .rect(cornerRadius: 22, style: .continuous))
}
}
}
/// Seven days around the chosen one in a glass strip; arrows step a week.
struct DateStrip: View {
@Binding var selected: CivilDate
var body: some View {
let monday = selected.monday
let today = CivilDate.today()
HStack(spacing: 2) {
Button("Предыдущая неделя", systemImage: "chevron.left") { withAnimation(.smooth) { selected = selected.adding(days: -7) } }
.labelStyle(.iconOnly).frame(width: 30)
ForEach(0..<7, id: \.self) { i in
let d = monday.adding(days: i)
let isSelected = d == selected
Button { withAnimation(.smooth) { selected = d } } label: {
VStack(spacing: 3) {
Text(d.shortWeekday).font(.caption2.weight(.medium)).foregroundStyle(d == today ? Color.brand : .secondary)
Text("\(d.day)")
.font(.callout.weight(isSelected ? .bold : .regular))
.foregroundStyle(isSelected ? Color.onBrand : .primary)
.frame(width: 32, height: 32)
.background { if isSelected { Circle().fill(Color.brand) } }
}
.frame(maxWidth: .infinity)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(d.weekdayName + ", " + d.longLabel)
}
Button("Следующая неделя", systemImage: "chevron.right") { withAnimation(.smooth) { selected = selected.adding(days: 7) } }
.labelStyle(.iconOnly).frame(width: 30)
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 26, style: .continuous))
}
}