- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
7.0 KiB
Swift
151 lines
7.0 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) {
|
||
// Dates stay on one line when a day has the town under it.
|
||
HStack(alignment: .top, spacing: 0) {
|
||
Color.clear.frame(width: gutter, height: 1)
|
||
ForEach(days, id: \.0) { day in
|
||
let d = day.0
|
||
let isToday = d == today
|
||
let places = model.places(timetable, on: d)
|
||
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)
|
||
// "Занятия в Полоцке": the town of the whole day, under its date.
|
||
DayPlaceMark(places: places)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel(d.weekdayName + ", " + d.longLabel + places.map { ", " + $0.text }.joined())
|
||
}
|
||
}
|
||
.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)
|
||
// A narrow column keeps the room whole: the day's town is under its date.
|
||
if let room = lesson.distance ? (show.rooms ? "дист." : nil) : (wide ? withPlace(show.rooms ? lesson.rooms.first : nil, lesson.place) : (show.rooms ? lesson.rooms.first : nil) ?? lesson.place) {
|
||
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))
|
||
// Exams and credits: an amber outline and the kind's symbol in the corner.
|
||
.overlay(alignment: .bottomTrailing) {
|
||
if let session = lesson.session {
|
||
ZStack(alignment: .bottomTrailing) {
|
||
RoundedRectangle(cornerRadius: 8, style: .continuous).strokeBorder(Color.sessionAccent, lineWidth: 2)
|
||
SessionDot(kind: session, size: 16).padding(3)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel((lesson.session.map { "\($0.label): " } ?? "") + "\(lesson.subject), \(lesson.start)" + (withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place).map { ", " + $0 } ?? ""))
|
||
}
|
||
}
|