- 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.
137 lines
5.9 KiB
Swift
137 lines
5.9 KiB
Swift
import SwiftUI
|
|
|
|
/// The week as a time grid: one column per day, a coloured block per lesson.
|
|
struct WeekGrid: View {
|
|
@Environment(AppModel.self) private var model
|
|
var timetable: Timetable
|
|
var monday: CivilDate
|
|
var onOpen: (LessonRef) -> Void
|
|
|
|
private let hour: CGFloat = 60
|
|
private let gutter: CGFloat = 32
|
|
|
|
var body: some View {
|
|
let all = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(timetable, on: $0)) }
|
|
// Sunday only takes a column when something happens on it.
|
|
let days = all.filter { $0.0.weekday < 7 || !$0.1.isEmpty }
|
|
let slots = days.flatMap(\.1)
|
|
let first = min(8, slots.map(\.start.hour).min() ?? 8)
|
|
let last = max(18, slots.map { $0.end.hour + ($0.end.minute > 0 ? 1 : 0) }.max() ?? 18)
|
|
let hours = last - first
|
|
let today = model.today
|
|
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 0) {
|
|
Color.clear.frame(width: gutter, height: 1)
|
|
ForEach(days, id: \.0) { day in
|
|
let d = day.0
|
|
let isToday = d == today
|
|
VStack(spacing: 2) {
|
|
Text(d.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary)
|
|
Text("\(d.day)")
|
|
.font(.headline)
|
|
.foregroundStyle(isToday ? Color.onBrand : .primary)
|
|
.frame(width: 34, height: 34)
|
|
.background(isToday ? Color.brand : .clear, in: .circle)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(d.weekdayName + ", " + d.longLabel)
|
|
}
|
|
}
|
|
.padding(.bottom, 8)
|
|
|
|
ScrollView {
|
|
HStack(alignment: .top, spacing: 0) {
|
|
ZStack(alignment: .topLeading) {
|
|
ForEach(first..<last, id: \.self) { h in
|
|
Text("\(h)")
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
.offset(x: 10, y: hour * CGFloat(h - first) - 7)
|
|
}
|
|
}
|
|
.frame(width: gutter, height: hour * CGFloat(hours), alignment: .topLeading)
|
|
.accessibilityHidden(true)
|
|
|
|
ForEach(days, id: \.0) { day in
|
|
column(day.0, day.1, first: first, hours: hours, today: today)
|
|
}
|
|
}
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 24)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func y(_ t: ClockTime, _ first: Int) -> CGFloat { hour * CGFloat(t.minutes - first * 60) / 60 }
|
|
|
|
private func column(_ d: CivilDate, _ daySlots: [Slot], first: Int, hours: Int, today: CivilDate) -> some View {
|
|
GeometryReader { geo in
|
|
let width = geo.size.width
|
|
ZStack(alignment: .topLeading) {
|
|
Path { p in
|
|
for h in 0...hours {
|
|
p.move(to: CGPoint(x: 0, y: hour * CGFloat(h)))
|
|
p.addLine(to: CGPoint(x: width, y: hour * CGFloat(h)))
|
|
}
|
|
p.move(to: .zero)
|
|
p.addLine(to: CGPoint(x: 0, y: hour * CGFloat(hours)))
|
|
}
|
|
.stroke(Color(.separator), lineWidth: 0.5)
|
|
|
|
ForEach(daySlots) { slot in
|
|
let top = y(slot.start, first)
|
|
let height = y(slot.end, first) - top
|
|
let w = width / CGFloat(slot.lessons.count)
|
|
ForEach(Array(slot.lessons.enumerated()), id: \.element.key) { i, lesson in
|
|
Button { onOpen(LessonRef(lesson: lesson, date: d)) } label: {
|
|
LessonBlock(lesson: lesson, wide: w >= 96, notes: model.notes.forLesson(lesson.subject, d, lesson.start).open.count)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: max(0, w - 4), height: max(0, height - 2))
|
|
.offset(x: w * CGFloat(i) + 2, y: top + 1)
|
|
}
|
|
}
|
|
|
|
if d == today, (first..<(first + hours)).contains(model.clock.hour) {
|
|
Capsule().fill(Color.brand).frame(height: 2).offset(y: y(model.clock, first) - 1)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: hour * CGFloat(hours))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
/// A lesson in the grid: its abbreviation (the full name on wide columns) and room.
|
|
private struct LessonBlock: View {
|
|
@Environment(\.display) private var show
|
|
var lesson: Lesson
|
|
var wide: Bool
|
|
var notes: Int
|
|
|
|
var body: some View {
|
|
WithSubjectColor(subject: lesson.subject) { c in
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(wide ? lesson.subject : abbreviate(lesson.subject))
|
|
.font(.caption.weight(.semibold))
|
|
.lineLimit(wide ? 3 : 2)
|
|
if show.rooms, let room = lesson.distance ? "дист." : lesson.rooms.first {
|
|
Text(room).font(.caption2).lineLimit(1).opacity(0.9)
|
|
}
|
|
Spacer(minLength: 0)
|
|
if notes > 0 {
|
|
Image(systemName: "checklist").font(.caption2.weight(.bold))
|
|
}
|
|
}
|
|
.foregroundStyle(c.onStrong)
|
|
.padding(4)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.background(c.strong, in: .rect(cornerRadius: 8, style: .continuous))
|
|
}
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel("\(lesson.subject), \(lesson.start)" + (lesson.rooms.isEmpty ? "" : ", ауд. " + lesson.rooms.joined(separator: ", ")))
|
|
}
|
|
}
|