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

115 lines
5.8 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
extension ScheduleChanges: Identifiable {
var id: String { "\(document)|\(group)|\(at.timeIntervalSince1970)" }
}
/**
* The overview's card after the university republished the group's document: how many lessons
* changed, "Показать" for the list, and a close button. Both mark the record as seen.
*/
struct ChangesCard: View {
var record: ScheduleChanges
var onShow: () -> Void
var onClose: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: "arrow.triangle.2.circlepath.doc.on.clipboard")
.font(.body.weight(.semibold))
.foregroundStyle(Color.onBrand)
.frame(width: 40, height: 40)
.background(Color.brand, in: .circle)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Расписание обновлено · \(changesLabel(record.count))").font(.headline)
Text("Для \(record.group)" + (fileDateLabel(record.to).map { " · файл от \($0)" } ?? ""))
.font(.subheadline).foregroundStyle(.secondary)
Button("Показать", action: onShow)
.buttonStyle(.glassProminent)
.padding(.top, 6)
}
.frame(maxWidth: .infinity, alignment: .leading)
Button("Скрыть сообщение об изменениях", systemImage: "xmark", action: onClose)
.labelStyle(.iconOnly)
.buttonStyle(.glass)
.buttonBorderShape(.circle)
}
.card(padding: 18)
}
}
/// The list behind the card: changed, added and removed lessons, as in the PWA.
struct ChangesSheet: View {
@Environment(\.dismiss) private var dismiss
var record: ScheduleChanges
var body: some View {
NavigationStack {
List {
Section {
Text("\(record.group) · \(record.name.replacingOccurrences(of: #"\.docx?$"#, with: "", options: [.regularExpression, .caseInsensitive]))"
+ (fileDateLabel(record.to).map { " · файл от \($0)" } ?? "")
+ ". Сравнение с прошлой версией, сохранённой на этом устройстве.")
.font(.subheadline).foregroundStyle(.secondary)
}
if !record.changed.isEmpty {
Section("Изменено · \(record.changed.count)") {
ForEach(record.changed, id: \.self) { change in
VStack(alignment: .leading, spacing: 4) {
when(change.after)
Text(change.after.lesson.subject).font(.headline)
ForEach(change.fields, id: \.self) { f in
(Text("\(f.label): ").foregroundStyle(.secondary)
+ Text(f.from).strikethrough().foregroundStyle(.secondary)
+ Text(" → ").foregroundStyle(.secondary)
+ Text(f.to))
.font(.subheadline)
.accessibilityLabel("\(f.label): было \(f.from), стало \(f.to)")
}
if change.fields.isEmpty {
Text("Изменился текст ячейки Word").font(.subheadline).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
}
if !record.added.isEmpty {
Section("Добавлено · \(record.added.count)") {
ForEach(record.added, id: \.self) { row in
item(row, details: [row.lesson.type, row.lesson.teachers.joined(separator: ", "),
withPlace(row.lesson.rooms.map { "ауд. \($0)" }.joined(separator: ", "), row.lesson.place) ?? "", row.lesson.condition])
}
}
}
if !record.removed.isEmpty {
Section("Удалено · \(record.removed.count)") {
ForEach(record.removed, id: \.self) { row in
item(row, removed: true, details: [row.lesson.type, row.lesson.condition])
}
}
}
Section {} footer: { Text("Сверяйтесь с документом Word: при сомнениях он главнее.") }
}
.navigationTitle("Изменения")
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
}
}
private func when(_ row: ChangedLesson) -> some View {
let day = row.date.flatMap { CivilDate($0) }.map { "\($0.shortWeekday), \($0.longLabel)" } ?? dayName(row.day)
return Text(day + " · " + row.lesson.start).font(.subheadline.weight(.semibold)).foregroundStyle(Color.brand)
}
private func item(_ row: ChangedLesson, removed: Bool = false, details: [String]) -> some View {
VStack(alignment: .leading, spacing: 4) {
when(row).foregroundStyle(removed ? .secondary : Color.brand)
Text(row.lesson.subject).font(.headline).strikethrough(removed).foregroundStyle(removed ? .secondary : .primary)
let text = details.filter { !$0.trimmed.isEmpty }.joined(separator: " · ")
if !text.isEmpty { Text(text).font(.subheadline).foregroundStyle(.secondary) }
}
.padding(.vertical, 2)
}
}