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