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

241 lines
11 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 month with marks on days with lessons and with plans, green weeks highlighted;
* below it, the chosen day's plans and lessons.
*/
struct CalendarView: View {
@Environment(AppModel.self) private var model
@Environment(\.horizontalSizeClass) private var sizeClass
@State private var month: CivilDate?
@State private var selected: CivilDate?
@State private var details: LessonRef?
@State private var editor: EditorRequest?
@State private var adding = false
@State private var forward = true
var body: some View {
let date = selected ?? model.today
let shown = month ?? date.firstOfMonth
NavigationStack {
Group {
if sizeClass == .regular {
HStack(alignment: .top, spacing: 16) {
ScrollView { monthCard(shown, date).padding(16) }.frame(width: 460)
ScrollView { agenda(date).padding(16) }
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 28, style: .continuous))
.padding([.trailing, .bottom], 16)
}
} else {
ScrollView {
VStack(spacing: 16) {
monthCard(shown, date)
agenda(date)
}
.padding(16)
.padding(.bottom, 60)
.readableWidth()
}
}
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Планы")
.navigationSubtitle(shown.monthTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if date != model.today || shown != model.today.firstOfMonth {
ToolbarItem(placement: .topBarLeading) {
Button("Сегодня", systemImage: "calendar.badge.clock") {
withAnimation(.smooth) {
forward = model.today > date
selected = model.today
month = model.today.firstOfMonth
}
}
}
}
ToolbarItem(placement: .topBarTrailing) { PlansModeButton() }
}
.overlay(alignment: .bottomTrailing) {
Button { adding = true } label: {
Image(systemName: "plus").font(.title2.weight(.semibold)).frame(width: 36, height: 36)
}
.buttonStyle(.glassProminent)
.buttonBorderShape(.circle)
.controlSize(.large)
.padding(20)
.accessibilityLabel("Новый план")
}
}
.sheet(isPresented: $adding) {
AddPlanSheet(day: date) { target in
adding = false
editor = EditorRequest(target: target)
}
}
.sheet(item: $editor) { NoteEditor(request: $0) }
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
}
private func monthCard(_ shown: CivilDate, _ date: CivilDate) -> some View {
VStack(spacing: 12) {
HStack {
Text(shown.monthTitle).font(.title3.bold())
Spacer()
GlassEffectContainer(spacing: 8) {
HStack(spacing: 8) {
Button("Предыдущий месяц", systemImage: "chevron.left") { go(shown.adding(months: -1)) }
Button("Следующий месяц", systemImage: "chevron.right") { go(shown.adding(months: 1)) }
}
.labelStyle(.iconOnly)
.buttonStyle(.glass)
}
}
MonthGrid(month: shown, selected: date) { d in
withAnimation(.smooth) {
forward = d > date
selected = d
if d.firstOfMonth != shown { month = d.firstOfMonth }
}
}
.id(shown)
.transition(.push(from: forward ? .trailing : .leading))
.gesture(
DragGesture(minimumDistance: 30).onEnded { g in
if abs(g.translation.width) > abs(g.translation.height) { go(shown.adding(months: g.translation.width < 0 ? 1 : -1)) }
}
)
}
.card(padding: 16)
.clipped()
}
private func go(_ target: CivilDate) {
withAnimation(.smooth) {
forward = target > (month ?? model.today.firstOfMonth)
month = target.firstOfMonth
}
}
@ViewBuilder private func agenda(_ date: CivilDate) -> some View {
DayAgenda(date: date, details: $details, editor: $editor)
.id(date)
.transition(.push(from: forward ? .trailing : .leading))
}
}
private struct MonthGrid: View {
@Environment(AppModel.self) private var model
var month: CivilDate
var selected: CivilDate
var onSelect: (CivilDate) -> Void
var body: some View {
let first = month.firstOfMonth.monday
let next = month.adding(months: 1).firstOfMonth
let rows = stride(from: 0, to: 42, by: 7).map { first.adding(days: $0) }.filter { $0 < next }
VStack(spacing: 4) {
HStack(spacing: 0) {
ForEach(["п", "в", "с", "ч", "п", "с", "в"].indices, id: \.self) { i in
Text(["П", "В", "С", "Ч", "П", "С", "В"][i]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).frame(maxWidth: .infinity)
}
}
.accessibilityHidden(true)
ForEach(rows, id: \.self) { monday in
let week = model.timetable?.day(monday)?.week
HStack(spacing: 0) {
ForEach(0..<7, id: \.self) { i in
cell(monday.adding(days: i))
}
}
.background {
// The university's green weeks, as on the week strips.
if let week, !week.white {
RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color.greenWeek.opacity(0.16))
}
}
}
}
}
private func cell(_ d: CivilDate) -> some View {
let inMonth = d.month == month.month
let isSelected = d == selected
let isToday = d == model.today
let lessons = model.timetable.map { !model.slots($0, on: d).isEmpty } ?? false
let plans = model.notes.contains { $0.date == d.iso }
let session = model.timetable.map { model.slots($0, on: d).contains { $0.lessons.contains { $0.session != nil } } } ?? false
let color: Color = isSelected ? .onBrand : isToday ? .brand : inMonth ? .primary : Color(.tertiaryLabel)
return Button { onSelect(d) } label: {
VStack(spacing: 3) {
Text("\(d.day)")
.font(.callout.weight(isToday || isSelected ? .bold : .regular))
.foregroundStyle(color)
.frame(width: 34, height: 34)
.background { if isSelected { Circle().fill(Color.brand) } else if isToday { Circle().strokeBorder(Color.brand, lineWidth: 1.5) } }
HStack(spacing: 3) {
// Lessons in grey, an exam or credit in amber, plans in purple.
if session { RoundedRectangle(cornerRadius: 1.5).fill(Color.sessionAccent).frame(width: 6, height: 6) }
else { Circle().fill(lessons ? Color.secondary : .clear).frame(width: 4, height: 4) }
if plans { Circle().fill(Color.purple).frame(width: 4, height: 4) }
}
.frame(height: 4)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 2)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(d.weekdayName + ", " + d.longLabel + (lessons ? ", есть пары" : "") + (plans ? ", есть планы" : "") + (session ? ", экзамен или зачёт" : ""))
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
/// The chosen day: its plans first, then its lessons on a timeline.
private struct DayAgenda: View {
@Environment(AppModel.self) private var model
var date: CivilDate
@Binding var details: LessonRef?
@Binding var editor: EditorRequest?
var body: some View {
let slots = model.timetable.map { model.slots($0, on: date) } ?? []
let notes = model.notes.filter { $0.date == date.iso }
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .lastTextBaseline) {
Text(date == model.today ? "Сегодня" : date.weekdayName).font(.title2.bold())
Spacer()
Text(date.longLabel).font(.subheadline).foregroundStyle(.secondary)
if let week = model.timetable?.day(date)?.week { WeekMark(week: week, short: true) }
}
if slots.isEmpty && notes.isEmpty {
let outside = model.timetable.map { t in t.semester.map { date.iso < $0.start || date.iso > $0.end } ?? true } ?? true
EmptyState(
title: model.timetable == nil ? "Расписание не загружено" : "Свободный день",
text: outside && model.timetable != nil ? "Эта дата вне текущего семестра. Планов на \(date.longLabel) нет." : "\(date.weekdayName), \(date.longLabel): ни пар, ни планов.",
symbol: "sofa"
)
}
if !notes.isEmpty {
Text("Ваши планы").font(.headline)
VStack(spacing: 4) {
ForEach(notes) { note in NoteRow(note: note, showDue: false) { editor = EditorRequest(editing: note) } }
}
.card(padding: 14)
}
if !slots.isEmpty {
Text("Ваши занятия").font(.headline)
if let t = model.timetable { DayPlaces(places: model.places(t, on: date)) }
ForEach(slots) { slot in
ForEach(slot.lessons, id: \.key) { lesson in
Button { details = LessonRef(lesson: lesson, date: date) } label: {
LessonCard(lesson: lesson, time: slot.timeLabel, date: date)
}
.buttonStyle(.plain)
}
}
}
}
}
}