Files
psu-schedule/ios/PSUSchedule/Views/GroupsView.swift
Zakhar Timoshenko 170a44c377 Add date and time handling, models, schedule management, storage, and widget support
- 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.
2026-10-18 20:26:12 +03:00

247 lines
12 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
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 : [])
}
}