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

223 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
/// Whose day to show: a teacher or a room.
enum SearchTarget: Hashable {
case teacher(String)
case room(String)
var title: String {
switch self {
case .teacher(let name): name
case .room(let room): "Аудитория \(room)"
}
}
}
/// "Кто где": teachers and rooms across every timetable of the university, only online.
struct SearchView: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
@State private var hits: DirectorySearch?
@State private var error: String?
@State private var retries = 0
var body: some View {
NavigationStack {
List {
if let error {
Banner(text: error, offline: true, onRetry: { self.error = nil; retries += 1 })
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
}
if let hits, router.searchQuery.trimmed.count >= 2 {
if !hits.teachers.isEmpty {
Section("Преподаватели") {
ForEach(hits.teachers, id: \.name) { t in
NavigationLink(value: SearchTarget.teacher(t.name)) {
VStack(alignment: .leading, spacing: 2) {
Text(t.name).font(.body.weight(.medium))
Text(([t.faculties.compactMap(facultyShort).joined(separator: ", ")].filter { !$0.isEmpty } + ["\(t.count) \(plural(t.count, "занятие", "занятия", "занятий"))"]).joined(separator: " · "))
.font(.subheadline).foregroundStyle(.secondary)
}
}
}
}
}
if !hits.rooms.isEmpty {
Section("Аудитории") {
ForEach(hits.rooms, id: \.room) { r in
NavigationLink(value: SearchTarget.room(r.room)) {
Label("Аудитория \(r.room)", systemImage: "door.left.hand.open")
}
}
}
}
if hits.teachers.isEmpty && hits.rooms.isEmpty {
EmptyState(title: "Никого не нашли", text: "Ни преподавателя, ни аудитории с таким названием в расписаниях каталога.", symbol: "magnifyingglass")
.listRowBackground(Color.clear)
}
} else if error == nil {
EmptyState(
title: "Кто где",
text: "Найдите преподавателя или аудиторию — покажем занятия на выбранный день по всем расписаниям университета.",
symbol: "binoculars"
)
.listRowBackground(Color.clear)
}
}
.navigationTitle("Поиск")
.navigationDestination(for: SearchTarget.self) { DirectoryDayView(target: $0) }
}
.task(id: "\(router.searchQuery)|\(retries)") {
let q = router.searchQuery.trimmed
guard q.count >= 2 else { hits = nil; return }
try? await Task.sleep(for: .milliseconds(250))
guard !Task.isCancelled else { return }
do {
hits = try await model.api.searchDirectory(q)
error = nil
} catch is CancellationError {
} catch {
self.error = error.localizedDescription
}
}
}
}
/// A teacher's or room's lessons on a day, with a glass strip of dates.
struct DirectoryDayView: View {
@Environment(AppModel.self) private var model
var target: SearchTarget
@State private var date = CivilDate.today().weekday == 7 ? CivilDate.today().adding(days: 1) : CivilDate.today()
@State private var day: DirectoryDay?
@State private var error: String?
@State private var loading = false
@State private var retries = 0
@State private var bells: [Bell] = []
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
if let error {
Banner(text: error, offline: true, onRetry: { self.error = nil; retries += 1 })
}
if loading && day == nil {
ProgressView().frame(maxWidth: .infinity).padding(.top, 60)
} else if let day {
if day.lessons.isEmpty {
EmptyState(title: "Занятий нет", text: "По расписаниям каталога в этот день здесь пусто.", symbol: "calendar.badge.checkmark")
.padding(.top, 40)
}
ForEach(Array(day.lessons.enumerated()), id: \.offset) { _, l in
DirectoryLessonCard(lesson: l, target: target, bells: bells)
}
Text(coverageNote(day.coverage))
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 8)
}
}
.padding(16)
.readableWidth()
}
.safeAreaBar(edge: .top, spacing: 0) {
DateStrip(selected: $date).padding(.horizontal, 12).padding(.bottom, 6).readableWidth()
}
.background(Color(.systemGroupedBackground))
.navigationTitle(target.title)
.navigationSubtitle(date.weekdayName + ", " + date.longLabel)
.navigationBarTitleDisplayMode(.inline)
.task { if bells.isEmpty { bells = (try? await model.api.calendar().bells) ?? model.timetable?.bells ?? [] } }
.task(id: "\(date.iso)|\(retries)") {
loading = true
defer { loading = false }
do {
day = switch target {
case .teacher(let name): try await model.api.teacherDay(name, date: date)
case .room(let room): try await model.api.roomDay(room, date: date)
}
error = nil
} catch is CancellationError {
} catch {
self.error = error.localizedDescription
}
}
}
private func coverageNote(_ c: Coverage) -> String {
var s = "По расписаниям из Word: учтено \(c.documents) из \(c.total) документов каталога \(c.year)."
if !c.failed.isEmpty { s += " Не распознано: \(c.failed.count)." }
if !c.withoutSemester.isEmpty { s += " Без дат (нет семестра в таблице недель): \(c.withoutSemester.count)." }
return s
}
}
private struct DirectoryLessonCard: View {
var lesson: DirectoryLesson
var target: SearchTarget
var bells: [Bell]
var body: some View {
let pair = bells.first { $0.start == lesson.start }?.pair
WithSubjectColor(subject: lesson.subject) { c in
VStack(alignment: .leading, spacing: 6) {
Text(lesson.subject).font(.headline)
Text(lesson.start + (lesson.end.map { " → \($0)" } ?? "") + (pair.map { " · \($0) пара" } ?? "")).font(.body.monospacedDigit())
FlowLayout(spacing: 14) {
if case .teacher = target {
Meta(symbol: "mappin.and.ellipse", text: lesson.distance ? "Дистанционно" : withPlace(lesson.rooms.joined(separator: ", ") + (lesson.moved ? " (перенос)" : ""), lesson.place) ?? "")
} else if !lesson.teachers.isEmpty {
Meta(symbol: "person", text: lesson.teachers.joined(separator: ", "))
}
if !lesson.groups.isEmpty { Meta(symbol: "person.3", text: lesson.groups.joined(separator: ", ")) }
}
FlowLayout(spacing: 6) {
if !lesson.type.isEmpty { Tag(text: lesson.type, foreground: c.onSoft, background: c.onSoft.opacity(0.12)) }
if lesson.uncertain { Tag(text: "Сверьте недели", foreground: .red, background: .red.opacity(0.14)) }
}
}
.foregroundStyle(c.onSoft)
.padding(.horizontal, 18).padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(c.soft, in: .rect(cornerRadius: 22, style: .continuous))
}
}
}
/// Seven days around the chosen one in a glass strip; arrows step a week.
struct DateStrip: View {
@Binding var selected: CivilDate
var body: some View {
let monday = selected.monday
let today = CivilDate.today()
HStack(spacing: 2) {
Button("Предыдущая неделя", systemImage: "chevron.left") { withAnimation(.smooth) { selected = selected.adding(days: -7) } }
.labelStyle(.iconOnly).frame(width: 30)
ForEach(0..<7, id: \.self) { i in
let d = monday.adding(days: i)
let isSelected = d == selected
Button { withAnimation(.smooth) { selected = d } } label: {
VStack(spacing: 3) {
Text(d.shortWeekday).font(.caption2.weight(.medium)).foregroundStyle(d == 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) } }
}
.frame(maxWidth: .infinity)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityLabel(d.weekdayName + ", " + d.longLabel)
}
Button("Следующая неделя", systemImage: "chevron.right") { withAnimation(.smooth) { selected = selected.adding(days: 7) } }
.labelStyle(.iconOnly).frame(width: 30)
}
.padding(.vertical, 8)
.padding(.horizontal, 4)
.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 26, style: .continuous))
}
}