- 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>
241 lines
11 KiB
Swift
241 lines
11 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|