import SwiftUI private let plainPeriod = #/^(осенний|весенний) семестр$|^расписание из word$/#.ignoresCase() private func describe(_ faculty: String, _ course: String, _ form: String = "", _ period: String = "") -> String { var parts: [String] = [] if let f = facultyShort(faculty) { parts.append(f) } if !course.isEmpty { parts.append(course) } let form = form.replacingOccurrences(of: " форма", with: "").lowercased() if !form.isEmpty { parts.append(form) } if !period.isEmpty && (try? plainPeriod.wholeMatch(in: period)) == nil { parts.append(period.lowercased()) } return parts.joined(separator: " · ") } /** * Picking a group: search by number across the whole catalog, recent groups, the catalog by * faculty and course, and the subgroup of the open group. Students think in groups, not files. */ struct GroupsView: View { @Environment(AppModel.self) private var model @Environment(\.dismiss) private var dismiss /** A step of the onboarding: its own header, and the subgroup is asked on a page of its own. */ var onboarding: Bool var onOpened: () -> Void @State private var query = "" @State private var faculty = "" @State private var course = "" @State private var data: GroupsResponse? @State private var error: String? @State private var retries = 0 @State private var opening: String? @State private var askSubgroup: [String]? var body: some View { let current = model.settings.current let searching = query.trimmed.count >= 2 List { if onboarding || current == nil { VStack(alignment: .leading, spacing: 6) { Text("Найдите свою группу").font(.largeTitle.bold()) Text("Расписание откроется сразу и будет доступно без интернета.").foregroundStyle(.secondary) } .listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 8, leading: 4, bottom: 4, trailing: 4)) } Section { HStack(spacing: 10) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) TextField("Номер группы, например 23-БА", text: $query) .textInputAutocapitalization(.characters) .autocorrectionDisabled() .submitLabel(.search) if !query.isEmpty { Button("Очистить", systemImage: "xmark.circle.fill") { query = "" } .labelStyle(.iconOnly).foregroundStyle(.secondary).buttonStyle(.plain) } } .padding(.horizontal, 16) .padding(.vertical, 12) .glassEffect(.regular.interactive(), in: .capsule) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) } if let error { Banner(text: error, offline: true, onRetry: { self.error = nil; retries += 1 }) .listRowBackground(Color.clear).listRowInsets(EdgeInsets()) } if let message = model.status.message { Banner(text: message, offline: model.status.offline, onClose: { model.dismissMessage() }, onRetry: model.status.offline ? { Task { await model.retry() } } : nil) .listRowBackground(Color.clear).listRowInsets(EdgeInsets()) } if searching { let results = data?.results ?? [] if !results.isEmpty { Section("Найдено: \(results.count)") { rows(results) } } else if data != nil { EmptyState(title: "Группа не найдена", text: "Группы «\(query.trimmed)» нет в каталоге \(data?.year ?? ""). Проверьте номер.", symbol: "magnifyingglass") .listRowBackground(Color.clear) } } else { if let current, !onboarding { Section("Сейчас открыта") { GroupRow(group: current.group, subtitle: describe(current.faculty, current.course)) if let subgroups = model.timetable?.subgroups, !subgroups.isEmpty { Picker("Подгруппа", selection: Binding(get: { model.subgroup }, set: { model.setSubgroup($0) })) { Text("Все").tag(String?.none) ForEach(subgroups, id: \.self) { Text($0.replacingOccurrences(of: "Подгруппа ", with: "")).tag(Optional($0)) } } .pickerStyle(.segmented) } } } let recent = model.settings.recent.filter { $0.key != current?.key } if !recent.isEmpty { Section("Недавние") { ForEach(recent, id: \.key) { g in Button { Task { await model.open(g); opened() } } label: { GroupRow(group: g.group, subtitle: describe(g.faculty, g.course), chevron: true) } .foregroundStyle(.primary) } } } if let d = data { Section { VStack(alignment: .leading, spacing: 10) { Text("Факультет").font(.subheadline.weight(.semibold)).foregroundStyle(.secondary) FlowLayout(spacing: 8) { ForEach(d.facets.faculties, id: \.name) { f in Chip(label: facultyShort(f.name) ?? f.name, on: faculty == f.name) { faculty = faculty == f.name ? "" : f.name course = "" } } } if !faculty.isEmpty { Text("Курс").font(.subheadline.weight(.semibold)).foregroundStyle(.secondary).padding(.top, 4) FlowLayout(spacing: 8) { ForEach(d.facets.courses, id: \.name) { c in Chip(label: c.name, on: course == c.name) { course = course == c.name ? "" : c.name } } } } else { Text("Выберите факультет или введите номер группы. Всего в каталоге \(d.total) групп.") .font(.subheadline).foregroundStyle(.secondary) } } .padding(.vertical, 6) if !faculty.isEmpty { rows(d.results) } } header: { Text("Каталог \(d.year)") } } else if error == nil { ProgressView().frame(maxWidth: .infinity).listRowBackground(Color.clear) } } } .listStyle(.insetGrouped) .scrollDismissesKeyboard(.interactively) .navigationTitle(onboarding ? "" : "Группы") .navigationBarTitleDisplayMode(.inline) .toolbar { if !onboarding && current != nil { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } } } .task(id: "\(query)|\(faculty)|\(course)|\(retries)") { if !query.isEmpty { try? await Task.sleep(for: .milliseconds(250)) } guard !Task.isCancelled else { return } do { data = try await model.api.groups(query: query.trimmed, faculty: faculty, course: course) error = nil } catch is CancellationError { } catch { self.error = error.localizedDescription } } .alert("Ваша подгруппа", isPresented: Binding(get: { askSubgroup != nil }, set: { if !$0 { askSubgroup = nil } })) { ForEach(askSubgroup ?? [], id: \.self) { s in Button(s) { model.setSubgroup(s); askSubgroup = nil; onOpened() } } Button("Все подгруппы") { model.setSubgroup(nil); askSubgroup = nil; onOpened() } Button("Позже", role: .cancel) { askSubgroup = nil; onOpened() } } message: { Text("В группе \(model.timetable?.group ?? "") занятия делятся на подгруппы. Выберите свою — чужие пары скроются из расписания, виджетов и напоминаний. Изменить можно в разделе «Группы».") } .onAppear { if faculty.isEmpty { faculty = current?.faculty ?? "" } } } @ViewBuilder private func rows(_ results: [GroupResult]) -> some View { ForEach(results) { r in Button { opening = r.id Task { let ok = await model.open(r) opening = nil if ok { opened() } } } label: { GroupRow(group: r.group, subtitle: describe(r.faculty, r.course, r.form, r.period), chevron: opening != r.id, busy: opening == r.id) } .foregroundStyle(.primary) .disabled(opening != nil) } } /// A group with subgroups asks which one is yours, unless you already chose it. private func opened() { let subgroups = model.timetable?.subgroups ?? [] if !subgroups.isEmpty && model.subgroup == nil && !onboarding { askSubgroup = subgroups } else { onOpened() } } } private struct GroupRow: View { @Environment(\.palette) private var palette @Environment(\.colorScheme) private var scheme var group: String var subtitle: String var chevron = false var busy = false var body: some View { let label = String(group.split(separator: "-").dropFirst().first ?? Substring(group)).prefix(3) let c = palette.color(String(label), dark: scheme == .dark) HStack(spacing: 14) { Text(label.uppercased()) .font(.footnote.weight(.bold)) .foregroundStyle(c.onStrong) .frame(width: 40, height: 40) .background(c.strong, in: .circle) VStack(alignment: .leading, spacing: 2) { Text(group).font(.headline) Text(subtitle).font(.subheadline).foregroundStyle(.secondary) } Spacer() if busy { ProgressView() } else if chevron { Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) } } .contentShape(.rect) } } private struct Chip: View { var label: String var on: Bool var action: () -> Void var body: some View { Button(action: action) { HStack(spacing: 4) { if on { Image(systemName: "checkmark").font(.caption.weight(.bold)) } Text(label).font(.subheadline.weight(.medium)) } .padding(.horizontal, 12) .padding(.vertical, 7) .foregroundStyle(on ? Color.onBrand : .primary) .background(on ? Color.brand : Color(.tertiarySystemFill), in: .capsule) } .buttonStyle(.plain) .accessibilityAddTraits(on ? .isSelected : []) } }