Files
psu-schedule/ios/PSUSchedule/Views/WeekGrid.swift
Zakhar Timoshenko 10c85e3bb9 feat: read "Занятия в Полоцке" as a day's town, show it in all clients
- 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>
2026-10-21 01:26:54 +03:00

151 lines
7.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } ?? ""))
}
}