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)) } }