- Introduced `Dates.swift` for handling date and time in the university's timezone. - Created `Models.swift` to define data structures for timetable, lessons, and directory search results. - Implemented `Schedule.swift` for managing lesson slots and timetable functionalities. - Developed `Storage.swift` for user settings and data persistence. - Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
247 lines
12 KiB
Swift
247 lines
12 KiB
Swift
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 : [])
|
||
}
|
||
}
|