Files
psu-schedule/ios/PSUSchedule/Views/WeekGrid.swift
Zakhar Timoshenko 170a44c377 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.
2026-10-18 20:26:12 +03:00

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: ", ")))
}
}