Files
psu-schedule/ios/PSUSchedule/Views/ScheduleView.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

300 lines
14 KiB
Swift

import SwiftUI
/**
* The dated timetable: days page horizontally under a floating Liquid Glass strip of the week,
* tinted green on the university's green weeks; the week view is a time grid.
*/
struct ScheduleView: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var details: LessonRef?
var body: some View {
NavigationStack {
Group {
if let t = model.timetable, t.semester != nil, !t.days.isEmpty {
Planner(timetable: t, details: $details)
} else if let t = model.timetable {
WordRows(timetable: t) { details = LessonRef(lesson: $0, date: nil) }
.navigationTitle(t.group)
} else {
EmptyState(
title: "Расписание не загружено",
text: "Проверьте подключение или выберите группу заново.",
symbol: "icloud.slash",
action: model.settings.current != nil
? ("Повторить", { Task { await model.refresh() } })
: ("Выбрать группу", { router.groups = true })
)
.navigationTitle("Расписание")
}
}
.background(Color(.systemGroupedBackground))
}
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
}
}
private struct Planner: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
@Environment(\.horizontalSizeClass) private var sizeClass
var timetable: Timetable
@Binding var details: LessonRef?
@State private var selected: CivilDate?
@State private var gridMonday: CivilDate?
var body: some View {
let t = timetable
let dates = t.dates
// On Sunday the next study day is more useful than an empty page.
let start = model.today.weekday == 7 ? model.today.adding(days: 1) : model.today
let fallback = dates.contains(start) ? start : (dates.first { $0 >= start } ?? dates.last!)
let date = selected ?? fallback
let weekView = router.weekView ?? (sizeClass == .regular)
let monday = weekView ? (gridMonday ?? date.monday) : date.monday
let week = t.day(monday)?.week
let onToday = weekView ? monday == fallback.monday : date == fallback
Group {
if weekView {
WeekPager(timetable: t, monday: Binding(get: { monday }, set: { gridMonday = $0 })) { details = $0 }
.transition(.opacity)
} else {
// A bar, not a plain inset: it blurs the cards scrolling under the title and the strip.
DayPager(timetable: t, dates: dates, selection: Binding(get: { date }, set: { selected = $0 })) { details = $0 }
.safeAreaBar(edge: .top, spacing: 0) {
WeekStrip(timetable: t, selected: date, week: week) { selected = $0 }
.padding(.horizontal, 12)
.padding(.bottom, 6)
.readableWidth()
}
.transition(.opacity)
}
}
.animation(.smooth(duration: 0.35), value: weekView)
.navigationTitle(weekTitle(monday.adding(days: 3), week?.n))
.navigationSubtitle([t.group, model.subgroup?.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр."), week.map { $0.white ? "белая неделя" : "зелёная неделя" }].compactMap { $0 }.joined(separator: " · "))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if !onToday {
ToolbarItem(placement: .topBarLeading) {
Button("Сегодня", systemImage: "calendar.badge.clock") {
withAnimation(.smooth) {
if weekView { gridMonday = fallback.monday } else { selected = fallback }
}
}
}
}
ToolbarItemGroup(placement: .topBarTrailing) {
Button(weekView ? "Показать день" : "Показать неделю", systemImage: weekView ? "list.bullet.below.rectangle" : "calendar") {
if !weekView { gridMonday = date.monday }
router.weekView = !weekView
}
.contentTransition(.symbolEffect(.replace))
Button("Обновить", systemImage: "arrow.clockwise") { Task { await model.refresh() } }
.disabled(model.status.loading)
}
}
.onChange(of: router.focus, initial: true) { _, focus in
// A day or week opened from elsewhere: the overview, the calendar, a widget.
guard let focus else { return }
if dates.contains(focus) { selected = focus }
gridMonday = focus.monday
router.focus = nil
}
}
}
/// The days of the semester, one page each, swiped sideways.
private struct DayPager: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var dates: [CivilDate]
@Binding var selection: CivilDate
var onOpen: (LessonRef) -> Void
var body: some View {
// A paging scroll view rather than a page-style TabView: each day's list is an ordinary
// scroll view, so it starts below the week strip, ends above the tab bar and scrolls
// under their glass.
ScrollView(.horizontal) {
LazyHStack(spacing: 0) {
ForEach(dates) { d in
DayPage(timetable: timetable, date: d, onOpen: onOpen)
.containerRelativeFrame(.horizontal)
.id(d)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.paging)
.scrollIndicators(.hidden)
.scrollPosition(id: Binding(get: { selection }, set: { if let d = $0 { selection = d } }))
}
}
private struct DayPage: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var date: CivilDate
var onOpen: (LessonRef) -> Void
var body: some View {
let slots = model.slots(timetable, on: date)
let clock = model.clock
let isToday = date == model.today
let next = isToday ? slots.first { $0.start > clock } : nil
ScrollView {
LazyVStack(spacing: 12) {
StatusBanner()
if slots.isEmpty {
EmptyState(title: "Занятий нет", text: "По таблице недель в этот день свободно — можно отдохнуть.", symbol: "sofa")
.padding(.top, 60)
}
DayPlaces(places: model.places(timetable, on: date))
ForEach(slots) { slot in
let running = isToday && clock >= slot.start && clock < slot.end
let status: String? = running
? "Идёт · ещё \(minutesLabel(slot.end.minutes - clock.minutes))"
: (slot == next && slot.start.minutes - clock.minutes <= 120 ? "Через \(minutesLabel(slot.start.minutes - clock.minutes))" : nil)
ForEach(slot.lessons, id: \.key) { lesson in
Button { onOpen(LessonRef(lesson: lesson, date: date)) } label: {
LessonCard(lesson: lesson, time: slot.timeLabel, date: date, status: status, running: running)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.readableWidth()
.animation(.smooth, value: slots)
}
.refreshable { await model.refresh() }
}
}
/**
* The week as a floating glass strip: weekday, date and a dot on days with lessons. Swipe
* it or use the arrows for the neighbouring weeks; green weeks tint the glass.
*/
private struct WeekStrip: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var selected: CivilDate
var week: Week?
var onSelect: (CivilDate) -> Void
var body: some View {
let monday = selected.monday
let loaded = Set(timetable.dates)
HStack(spacing: 2) {
Button("Предыдущая неделя", systemImage: "chevron.left") { step(-1, loaded) }
.labelStyle(.iconOnly)
.frame(width: 30)
ForEach(0..<7, id: \.self) { i in
let d = monday.adding(days: i)
let isSelected = d == selected
let has = !model.slots(timetable, on: d).isEmpty
Button { if loaded.contains(d) { onSelect(d) } } label: {
VStack(spacing: 3) {
Text(d.shortWeekday).font(.caption2.weight(.medium))
.foregroundStyle(d == model.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) } }
Circle().fill(has ? Color.secondary : .clear).frame(width: 4, height: 4)
}
.frame(maxWidth: .infinity)
.contentShape(.rect)
}
.buttonStyle(.plain)
.disabled(!loaded.contains(d))
.accessibilityLabel(d.weekdayName + ", " + d.longLabel + (has ? "" : ", пар нет"))
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
Button("Следующая неделя", systemImage: "chevron.right") { step(1, loaded) }
.labelStyle(.iconOnly)
.frame(width: 30)
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
.glassEffect(.regular.tint(weekTint(week)).interactive(), in: .rect(cornerRadius: 26, style: .continuous))
.gesture(
DragGesture(minimumDistance: 24).onEnded { g in
if abs(g.translation.width) > abs(g.translation.height) { step(g.translation.width < 0 ? 1 : -1, loaded) }
}
)
.animation(.smooth, value: selected)
}
/// The same weekday in the neighbouring week, or the nearest loaded day.
private func step(_ direction: Int, _ loaded: Set<CivilDate>) {
let target = selected.adding(days: 7 * direction)
if loaded.contains(target) { onSelect(target); return }
let sorted = loaded.sorted()
if let near = sorted.min(by: { abs($0.days(to: target)) < abs($1.days(to: target)) }), near != selected { onSelect(near) }
}
}
/// The week grid; a sideways swipe moves to the neighbouring loaded week.
private struct WeekPager: View {
var timetable: Timetable
@Binding var monday: CivilDate
var onOpen: (LessonRef) -> Void
@State private var forward = true
var body: some View {
let mondays = Set(timetable.dates.map(\.monday))
WeekGrid(timetable: timetable, monday: monday, onOpen: onOpen)
.padding(.top, 8)
.id(monday)
.transition(.push(from: forward ? .trailing : .leading))
.simultaneousGesture(
DragGesture(minimumDistance: 30).onEnded { g in
guard abs(g.translation.width) > abs(g.translation.height) * 1.5 else { return }
let step = g.translation.width < 0 ? 7 : -7
let next = monday.adding(days: step)
guard mondays.contains(next) else { return }
forward = step > 0
withAnimation(.smooth) { monday = next }
}
)
}
}
/// When the week table has no semester for this schedule: the Word rows by weekday.
private struct WordRows: View {
@Environment(AppModel.self) private var model
var timetable: Timetable
var onOpen: (Lesson) -> Void
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 14) {
StatusBanner()
Banner(text: timetable.note ?? "Даты не рассчитаны: показаны строки Word.", offline: false)
ForEach(timetable.weekly, id: \.day) { day in
Text(dayName(day.day)).font(.title3.bold()).padding(.top, 8)
let lessons = day.lessons
.filter { model.subgroup == nil || $0.subgroup == nil || $0.subgroup == model.subgroup }
.filter { !model.hidden.contains($0.hideKey) }
if !lessons.isEmpty { DayPlaces(places: day.places.forSubgroup(model.subgroup)) }
ForEach(lessons, id: \.key) { lesson in
Button { onOpen(lesson) } label: {
LessonCard(lesson: lesson, time: lesson.start + (lesson.end.map { " → \($0)" } ?? ""), date: nil)
}
.buttonStyle(.plain)
}
}
}
.padding(16)
.readableWidth()
}
.refreshable { await model.refresh() }
}
}