- 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>
300 lines
14 KiB
Swift
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() }
|
|
}
|
|
}
|