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

516 lines
23 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
/// A lesson opened in the details sheet; `date` is nil for Word rows without dates.
struct LessonRef: Identifiable, Hashable {
var lesson: Lesson
var date: CivilDate?
var id: String { (date?.iso ?? "-") + "|" + lesson.start + "|" + lesson.key }
}
/// A lesson as a soft card in its subject's colour, like School Planner's day list.
struct LessonCard: View {
@Environment(AppModel.self) private var model
@Environment(\.display) private var show
var lesson: Lesson
var time: String?
var date: CivilDate?
var status: String?
var running = false
var body: some View {
WithSubjectColor(subject: lesson.subject) { c in
VStack(alignment: .leading, spacing: 6) {
// Exams, credits and consultations announce themselves before the subject.
if let session = lesson.session { SessionBadge(kind: session).padding(.bottom, 2) }
HStack(alignment: .top, spacing: 8) {
Text(lesson.subject).font(.headline).frame(maxWidth: .infinity, alignment: .leading)
if let status {
Text(status)
.font(.footnote.weight(.semibold))
.padding(.horizontal, 8).padding(.vertical, 3)
.foregroundStyle(running ? c.soft : c.onSoft)
.background(running ? c.onSoft : c.onSoft.opacity(0.12), in: .rect(cornerRadius: 8, style: .continuous))
}
}
if let time {
Text(time + (show.pairs ? lesson.pair.map { " · \($0) пара" } ?? "" : ""))
.font(.body.monospacedDigit())
}
FlowLayout(spacing: 14) {
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
else if let room = withPlace(
show.rooms && !lesson.rooms.isEmpty ? lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : "") : nil,
lesson.place
) {
Meta(symbol: "mappin.and.ellipse", text: room)
}
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) }
}
let mark = date.flatMap { model.marks.forLesson(lesson.subject, $0, lesson.start) }
let tags = show.tags ? tagsOf(lesson) : []
if !tags.isEmpty || lesson.uncertain || mark != nil {
FlowLayout(spacing: 6) {
if let mark {
Tag(text: mark.present ? "был" : "пропуск", symbol: mark.present ? "checkmark" : "xmark", foreground: c.onSoft, background: c.onSoft.opacity(0.12))
}
ForEach(tags, id: \.self) { Tag(text: $0, foreground: c.onSoft, background: c.onSoft.opacity(0.12)) }
if lesson.uncertain { Tag(text: "Сверьте недели", foreground: .red, background: .red.opacity(0.14)) }
}
.padding(.top, 4)
}
if show.notes && !lesson.condition.isEmpty {
Meta(symbol: "repeat", text: "Пометка в Word: \(lesson.condition)")
}
if let date { NotesHint(notes: model.notes.forLesson(lesson.subject, date, lesson.start)) }
}
.foregroundStyle(c.onSoft)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(c.soft, in: .rect(cornerRadius: 22, style: .continuous))
.overlay {
if running { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(c.onSoft, lineWidth: 2) }
else if lesson.session != nil { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(Color.sessionAccent, lineWidth: 2) }
}
.contentShape(.rect(cornerRadius: 22))
}
}
}
func tagsOf(_ lesson: Lesson) -> [String] {
var tags: [String] = []
if !lesson.type.isEmpty && lesson.session == nil { tags.append(lesson.type) }
if lesson.variant != "all" { tags.append(lesson.variant == "upper" ? "белые недели" : "зелёные недели") }
if lesson.groups.count > 1 { tags.append("общее занятие") }
return tags
}
/// A one-line hint on a lesson card: the first open task or note and how many more.
struct NotesHint: View {
var notes: [Note]
var body: some View {
let open = notes.open
if let first = open.first {
Label {
Text(first.text + (open.count > 1 ? " +\(open.count - 1)" : "")).lineLimit(1)
} icon: {
Image(systemName: first.task ? "checklist" : "note.text")
}
.font(.subheadline.weight(.semibold))
.padding(.top, 2)
}
}
}
/// Later lessons in a card: a coloured dot, the subject and "13:40 → 15:05 · ауд. 254В".
struct CompactLesson: View {
@Environment(\.display) private var show
var lesson: Lesson
var slot: Slot
var body: some View {
HStack(spacing: lesson.session == nil ? 14 : 10) {
if let session = lesson.session { SessionDot(kind: session, size: 22) } else { SubjectDot(subject: lesson.subject, size: 10) }
VStack(alignment: .leading, spacing: 2) {
Text(lesson.subject).font(.subheadline.weight(.semibold)).lineLimit(2).foregroundStyle(.primary)
Text(details).font(.subheadline.monospacedDigit()).foregroundStyle(lesson.session == nil ? Color.secondary : Color.sessionAccent)
}
Spacer(minLength: 0)
}
.contentShape(.rect)
}
private var details: String {
var parts = [slot.timeLabel]
if let session = lesson.session { parts.insert(session.label, at: 0) }
if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) }
if lesson.distance { parts.append("дистанционно") }
else if let room = withPlace(show.rooms && !lesson.rooms.isEmpty ? "ауд. " + lesson.rooms.joined(separator: ", ") : nil, lesson.place) { parts.append(room) }
if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) }
return parts.joined(separator: " · ")
}
}
// MARK: - Details
/// Everything about one lesson: time, teacher, room, attendance, tasks and the Word record.
struct LessonDetails: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
var ref: LessonRef
@State private var editor: EditorRequest?
var body: some View {
let lesson = ref.lesson
let bell = model.timetable?.bells.first { $0.start == lesson.start }
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
TypeTag(type: lesson.type)
HStack(alignment: .firstTextBaseline, spacing: 12) {
SubjectDot(subject: lesson.subject, size: 14)
Text(lesson.subject).font(.title2.bold()).accessibilityAddTraits(.isHeader)
}
VStack(alignment: .leading, spacing: 14) {
DetailRow(
symbol: "clock",
label: bell.map { "\($0.pair) пара" } ?? "Время",
value: lesson.start + (lesson.end.map { " — \($0)" } ?? ""),
extra: bell.flatMap { $0.halves.count > 1 ? $0.halves.map { $0.joined(separator: "–") }.joined(separator: ", ") : nil }
)
DetailRow(symbol: "person", label: "Преподаватель", value: lesson.teachers.isEmpty ? "Не указан" : lesson.teachers.joined(separator: ", "))
DetailRow(
symbol: "mappin.and.ellipse",
label: "Аудитория",
value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")),
extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil
)
if let place = lesson.place {
DetailRow(symbol: "building.2", label: "Город", value: place, extra: "Так указано в Word")
}
DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup)
}
.card()
if lesson.uncertain {
Banner(text: "Пометку «\(lesson.condition)» не удалось сопоставить с таблицей недель. Сверьте с Word.", offline: false)
}
if let date = ref.date, model.groupKey != nil {
if model.started(date, lesson.start) { AttendancePicker(lesson: lesson, date: date) }
LessonNotes(lesson: lesson, date: date, editor: $editor)
}
VStack(alignment: .leading, spacing: 8) {
Text("Исходная запись").font(.headline)
Text(lesson.raw)
.font(.body)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color(.tertiarySystemFill), in: .rect(cornerRadius: 18, style: .continuous))
}
if model.groupKey != nil {
VStack(alignment: .leading, spacing: 8) {
Button(role: .destructive) {
model.setHidden(lesson.hideKey, true)
dismiss()
} label: {
Label("Скрыть такие занятия", systemImage: "eye.slash")
}
.buttonStyle(.glass)
Text("«\(lesson.subject)»\(lesson.type.isEmpty ? "" : " · " + lesson.type.lowercased()) пропадёт из расписания, обзора, виджетов и напоминаний во все недели. Вернуть можно в настройках.")
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.padding(20)
}
.background(Color(.systemGroupedBackground))
.navigationTitle(ref.date.map { $0.weekdayName + ", " + $0.longLabel } ?? "Занятие")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово", systemImage: "checkmark") { dismiss() }
}
}
}
.sheet(item: $editor) { NoteEditor(request: $0) }
}
}
private struct TypeTag: View {
var type: String
var body: some View {
let color: Color = switch type {
case "Лекция": .brand
case "Практика": .purple
case "Экзамен", "Зачёт", "Защита курсовой работы", "Консультация": .sessionAccent
default: .gray
}
Text(type.isEmpty ? "Занятие" : type)
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 10).padding(.vertical, 4)
.foregroundStyle(color)
.background(color.opacity(0.14), in: .capsule)
}
}
private struct DetailRow: View {
var symbol: String
var label: String
var value: String
var extra: String?
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: symbol).foregroundStyle(.secondary).frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.subheadline).foregroundStyle(.secondary)
Text(value).font(.body.weight(.medium))
if let extra { Text(extra).font(.subheadline).foregroundStyle(.secondary) }
}
}
.accessibilityElement(children: .combine)
}
}
/// "Был" / "Пропустил"; choosing the marked answer again takes the mark back.
private struct AttendancePicker: View {
@Environment(AppModel.self) private var model
var lesson: Lesson
var date: CivilDate
var body: some View {
let mark = model.marks.forLesson(lesson.subject, date, lesson.start)
VStack(alignment: .leading, spacing: 8) {
Text("Посещаемость").font(.headline)
GlassEffectContainer(spacing: 8) {
HStack(spacing: 8) {
choice("Был", "checkmark", value: true, current: mark?.present)
choice("Пропустил", "xmark", value: false, current: mark?.present)
}
}
if mark != nil {
Text("Нажмите на отметку ещё раз, чтобы снять её.").font(.footnote).foregroundStyle(.secondary)
}
}
}
private func choice(_ label: String, _ symbol: String, value: Bool, current: Bool?) -> some View {
let on = current == value
return Button {
model.mark(lesson, on: date, present: on ? nil : value)
} label: {
Label(label, systemImage: on ? symbol : "circle").frame(maxWidth: .infinity)
}
.buttonStyle(.glass)
.tint(on ? (value ? .green : .red) : nil)
.accessibilityAddTraits(on ? .isSelected : [])
}
}
// MARK: - Tasks and notes
/// What the editor needs to file a new note: the lesson it opened from and the next one.
struct NoteTarget: Hashable {
var subject: String
var date: CivilDate
var start: String
var next: NextLesson?
}
struct NextLesson: Hashable {
var date: CivilDate
var start: String
}
/// Opens the editor for a new note (target) or an existing one (editing).
struct EditorRequest: Identifiable {
var target: NoteTarget?
var editing: Note?
var id: String { editing?.id ?? "new-" + (target.map { $0.date.iso + $0.start + $0.subject } ?? "") }
}
/// The lesson sheet's section: this lesson's tasks and notes and a button to add one.
private struct LessonNotes: View {
@Environment(AppModel.self) private var model
var lesson: Lesson
var date: CivilDate
@Binding var editor: EditorRequest?
var body: some View {
let notes = model.notes.forLesson(lesson.subject, date, lesson.start)
VStack(alignment: .leading, spacing: 6) {
Text("Задания и заметки").font(.headline)
if notes.isEmpty {
Text("Запишите домашнее задание или напоминание — оно появится в карточке пары и в обзоре.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
ForEach(notes) { note in
NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) }
}
Button {
let next = model.nextOccurrence(of: lesson.subject, after: date, start: lesson.start)
editor = EditorRequest(target: NoteTarget(
subject: lesson.subject, date: date, start: lesson.start,
next: next.map { NextLesson(date: $0.0, start: $0.1.start) }
))
} label: {
Label("Добавить", systemImage: "plus")
}
.buttonStyle(.glass)
.padding(.top, 6)
}
}
}
/// A task with its checkbox or a note with its icon; a tap opens the editor.
struct NoteRow: View {
@Environment(AppModel.self) private var model
var note: Note
var showSubject = true
var showDue = true
var onEdit: () -> Void
var body: some View {
let overdue = note.overdue(today: model.today)
HStack(alignment: .center, spacing: 12) {
if note.task {
Button {
withAnimation(.snappy) { model.toggle(note) }
} label: {
Image(systemName: note.done ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(note.done ? Color.brand : Color.secondary)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
.accessibilityLabel(note.done ? "Выполнено" : "Не выполнено")
} else {
Image(systemName: "note.text").font(.title3).foregroundStyle(.secondary).frame(width: 28)
}
Button(action: onEdit) {
VStack(alignment: .leading, spacing: 2) {
Text(note.text)
.strikethrough(note.task && note.done)
.foregroundStyle(note.task && note.done ? .secondary : .primary)
.lineLimit(3)
HStack(spacing: 6) {
if showSubject && !note.general { SubjectDot(subject: note.subject, size: 8) }
Text(subtitle(overdue: overdue))
.font(.subheadline)
.foregroundStyle(overdue ? Color.red : Color.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
.padding(.vertical, 4)
}
private func subtitle(overdue: Bool) -> String {
var parts: [String] = []
if showSubject { parts.append(note.general ? "Общий план" : note.subject) }
if overdue { parts.append("просрочено") }
else if showDue { parts.append(note.dueLabel(today: model.today)) }
else if !note.start.isEmpty { parts.append("к " + note.start) }
return parts.joined(separator: " · ")
}
}
/**
* Adds or edits a task or a note. A new one goes to this lesson or, for homework set in
* class, to the next lesson of the subject; once the lesson is over the next one is the default.
*/
struct NoteEditor: View {
@Environment(AppModel.self) private var model
@Environment(\.dismiss) private var dismiss
var request: EditorRequest
@State private var text = ""
@State private var task = true
@State private var toNext = false
@State private var day = Date()
@FocusState private var focused: Bool
private var general: Bool { request.editing?.general ?? (request.target?.subject.trimmed.isEmpty ?? true) }
var body: some View {
NavigationStack {
Form {
Section {
Picker("Вид", selection: $task) {
Text("Задание").tag(true)
Text("Заметка").tag(false)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
Section {
TextField(task ? "Например, сдать лабораторную 3" : "Например, принести конспект", text: $text, axis: .vertical)
.lineLimit(3...8)
.focused($focused)
.textInputAutocapitalization(.sentences)
}
if general {
Section("День") {
DatePicker("День", selection: $day, displayedComponents: .date)
.environment(\.timeZone, minsk)
}
}
if request.editing == nil, let target = request.target, let next = target.next {
Section {
Picker("К какому занятию", selection: $toNext) {
Text("Эта пара").tag(false)
Text("Следующая").tag(true)
}
.pickerStyle(.segmented)
} header: {
Text("К какому занятию")
} footer: {
let (d, start) = toNext ? (next.date, next.start) : (target.date, target.start)
Text(d.shortWeekday + ", " + d.longLabel + ", " + start)
}
}
if let editing = request.editing {
Section {
Button("Удалить", role: .destructive) {
model.delete(editing)
dismiss()
}
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена", systemImage: "xmark") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Сохранить", systemImage: "checkmark") { save() }
.disabled(text.trimmed.isEmpty)
}
}
}
.presentationDetents([.medium, .large])
.onAppear {
if let e = request.editing {
text = e.text
task = e.task
day = (CivilDate(e.date) ?? model.today).date
} else if let t = request.target {
day = t.date.date
// Once the lesson has begun, homework most likely belongs to the next one.
toNext = t.next != nil && model.started(t.date, t.start) && !t.start.isEmpty
}
focused = true
}
}
private var title: String {
if request.editing != nil { return task ? "Задание" : "Заметка" }
if general { return task ? "Новый общий план" : "Новая заметка на день" }
return task ? "Новое задание" : "Новая заметка"
}
private func save() {
guard let group = request.editing?.group ?? model.groupKey else { return }
var note: Note
if let e = request.editing {
note = e
} else if let t = request.target {
let (d, start) = toNext && t.next != nil ? (t.next!.date, t.next!.start) : (t.date, t.start)
note = Note(group: group, subject: t.subject, date: d.iso, start: start, text: "")
} else { return }
if general { note.date = CivilDate(day).iso }
note.text = text.trimmed
note.task = task
model.save(note)
dismiss()
}
}