- 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.
307 lines
12 KiB
Swift
307 lines
12 KiB
Swift
import SwiftUI
|
||
|
||
/// The first run, in the order a student needs it: what this is, their group, subgroup, reminders.
|
||
private enum Step: Int, Comparable {
|
||
case welcome, group, subgroup, reminders
|
||
static func < (a: Step, b: Step) -> Bool { a.rawValue < b.rawValue }
|
||
}
|
||
|
||
/**
|
||
* First-run onboarding. Only the group is required: the welcome leads straight to it, the
|
||
* subgroup page appears only for groups split into subgroups, and reminders can wait.
|
||
*/
|
||
struct OnboardingView: View {
|
||
@Environment(AppModel.self) private var model
|
||
var onDone: () -> Void
|
||
@State private var step = Step.welcome
|
||
@State private var forward = true
|
||
|
||
var body: some View {
|
||
let subgroups = model.timetable?.subgroups ?? []
|
||
// The steps the progress counts; the welcome is not one of them.
|
||
let steps: [Step] = subgroups.isEmpty ? [.group, .reminders] : [.group, .subgroup, .reminders]
|
||
VStack(spacing: 0) {
|
||
if step != .welcome {
|
||
StepBar(index: steps.firstIndex(of: step) ?? 0, count: steps.count) { go(previous(step, subgroups)) }
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
ZStack {
|
||
switch step {
|
||
case .welcome:
|
||
Welcome { go(.group) }.transition(slide)
|
||
case .group:
|
||
NavigationStack {
|
||
GroupsView(onboarding: true) {
|
||
go((model.timetable?.subgroups ?? []).isEmpty ? .reminders : .subgroup)
|
||
}
|
||
}
|
||
.transition(slide)
|
||
case .subgroup:
|
||
SubgroupStep(group: model.timetable?.group ?? "", subgroups: subgroups) { choice in
|
||
model.setSubgroup(choice)
|
||
go(.reminders)
|
||
}
|
||
.transition(slide)
|
||
case .reminders:
|
||
RemindersStep { on in
|
||
model.setReminders(on)
|
||
onDone()
|
||
}
|
||
.transition(slide)
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.background(Color(.systemGroupedBackground))
|
||
}
|
||
|
||
private var slide: AnyTransition {
|
||
.asymmetric(
|
||
insertion: .move(edge: forward ? .trailing : .leading).combined(with: .opacity),
|
||
removal: .move(edge: forward ? .leading : .trailing).combined(with: .opacity)
|
||
)
|
||
}
|
||
|
||
private func go(_ next: Step) {
|
||
forward = next > step
|
||
withAnimation(.smooth(duration: 0.45)) { step = next }
|
||
}
|
||
|
||
private func previous(_ s: Step, _ subgroups: [String]) -> Step {
|
||
switch s {
|
||
case .welcome, .group: .welcome
|
||
case .subgroup: .group
|
||
case .reminders: subgroups.isEmpty ? .group : .subgroup
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Back to the previous step and how far along the student is.
|
||
private struct StepBar: View {
|
||
var index: Int
|
||
var count: Int
|
||
var onBack: () -> Void
|
||
|
||
var body: some View {
|
||
HStack(spacing: 14) {
|
||
Button("Назад", systemImage: "chevron.left", action: onBack)
|
||
.labelStyle(.iconOnly)
|
||
.buttonStyle(.glass)
|
||
.buttonBorderShape(.circle)
|
||
.controlSize(.large)
|
||
ProgressView(value: Double(index + 1), total: Double(count))
|
||
.tint(.brand)
|
||
.animation(.smooth, value: index)
|
||
.accessibilityHidden(true)
|
||
Text("\(index + 1) из \(count)").font(.subheadline.weight(.semibold)).foregroundStyle(.secondary).monospacedDigit()
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.vertical, 8)
|
||
}
|
||
}
|
||
|
||
private struct Feature: Identifiable {
|
||
var symbol: String
|
||
var color: Color
|
||
var title: String
|
||
var text: String
|
||
var id: String { title }
|
||
}
|
||
|
||
private let features = [
|
||
Feature(symbol: "calendar", color: .brand, title: "Пары на каждый день", text: "Даты уже рассчитаны по белым и зелёным неделям"),
|
||
Feature(symbol: "checklist", color: .greenWeek, title: "Задания к занятиям", text: "Планы и заметки рядом с предметами"),
|
||
Feature(symbol: "arrow.down.circle", color: .purple, title: "Без интернета", text: "Расписание хранится на телефоне"),
|
||
]
|
||
|
||
private struct Welcome: View {
|
||
var onStart: () -> Void
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 28) {
|
||
Hero().padding(.top, 32)
|
||
VStack(spacing: 10) {
|
||
Text("Расписание ПГУ").font(.largeTitle.bold()).accessibilityAddTraits(.isHeader)
|
||
Text("Пары вашей группы из документов университета — без таблиц Word на маленьком экране.")
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
VStack(alignment: .leading, spacing: 18) {
|
||
ForEach(features) { f in
|
||
HStack(spacing: 16) {
|
||
Image(systemName: f.symbol)
|
||
.font(.title3.weight(.semibold))
|
||
.foregroundStyle(f.color)
|
||
.frame(width: 48, height: 48)
|
||
.glassEffect(.regular.tint(f.color.opacity(0.18)), in: .circle)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(f.title).font(.headline)
|
||
Text(f.text).font(.subheadline).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(.horizontal, 28)
|
||
.frame(maxWidth: 520)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
.scrollBounceBehavior(.basedOnSize)
|
||
.safeAreaBar(edge: .bottom) {
|
||
VStack(spacing: 10) {
|
||
Button(action: onStart) {
|
||
Text("Найти свою группу").font(.headline).frame(maxWidth: .infinity).padding(.vertical, 6)
|
||
}
|
||
.buttonStyle(.glassProminent)
|
||
.controlSize(.large)
|
||
Text("Неофициальное приложение. Расписание берётся из публичного каталога ПГУ.")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
}
|
||
.padding(.horizontal, 28)
|
||
.padding(.bottom, 8)
|
||
.frame(maxWidth: 520)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The welcome's picture: the three features as Liquid Glass drops that drift together and
|
||
* apart inside one container, so their glass flows into one another.
|
||
*/
|
||
private struct Hero: View {
|
||
@Environment(\.accessibilityReduceMotion) private var still
|
||
@State private var together = false
|
||
|
||
var body: some View {
|
||
GlassEffectContainer(spacing: 30) {
|
||
ZStack {
|
||
drop("calendar", .brand, size: 150, icon: 60)
|
||
drop("checklist", .greenWeek, size: 72, icon: 28)
|
||
.offset(x: together ? 62 : 92, y: together ? -50 : -70)
|
||
drop("arrow.down.circle", .purple, size: 62, icon: 24)
|
||
.offset(x: together ? -60 : -88, y: together ? 52 : 70)
|
||
}
|
||
}
|
||
.frame(width: 280, height: 220)
|
||
.accessibilityHidden(true)
|
||
.onAppear {
|
||
guard !still else { return }
|
||
withAnimation(.easeInOut(duration: 3.2).repeatForever(autoreverses: true)) { together = true }
|
||
}
|
||
}
|
||
|
||
private func drop(_ symbol: String, _ color: Color, size: CGFloat, icon: CGFloat) -> some View {
|
||
Image(systemName: symbol)
|
||
.font(.system(size: icon, weight: .semibold))
|
||
.foregroundStyle(color)
|
||
.frame(width: size, height: size)
|
||
.glassEffect(.regular.tint(color.opacity(0.22)), in: .circle)
|
||
}
|
||
}
|
||
|
||
/// "Ваша подгруппа" as a page: one tap picks it and moves on.
|
||
private struct SubgroupStep: View {
|
||
var group: String
|
||
var subgroups: [String]
|
||
var onPick: (String?) -> Void
|
||
|
||
var body: some View {
|
||
List {
|
||
StepHeading(
|
||
symbol: "person.2",
|
||
title: "Ваша подгруппа",
|
||
text: "В группе \(group) часть занятий идёт по подгруппам. Выберите свою — чужие пары скроются из расписания, виджетов и напоминаний. Изменить можно в разделе «Группы»."
|
||
)
|
||
Section {
|
||
Button { onPick(nil) } label: { row("Все подгруппы", "Показывать занятия всех подгрупп") }
|
||
ForEach(subgroups, id: \.self) { s in
|
||
Button { onPick(s) } label: { row(s, nil) }
|
||
}
|
||
}
|
||
.tint(.primary)
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
.safeAreaBar(edge: .bottom) {
|
||
Button("Пропустить") { onPick(nil) }.padding(.bottom, 8)
|
||
}
|
||
}
|
||
|
||
private func row(_ title: String, _ text: String?) -> some View {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(title).font(.headline).foregroundStyle(.primary)
|
||
if let text { Text(text).font(.subheadline).foregroundStyle(.secondary) }
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||
}
|
||
.contentShape(.rect)
|
||
}
|
||
}
|
||
|
||
/// Reminders before each lesson; iOS asks for the notification permission here, when it makes sense.
|
||
private struct RemindersStep: View {
|
||
@Environment(AppModel.self) private var model
|
||
var onDone: (Bool) -> Void
|
||
|
||
var body: some View {
|
||
List {
|
||
StepHeading(
|
||
symbol: "bell.badge",
|
||
title: "Напоминать о парах?",
|
||
text: "Уведомление с предметом и аудиторией перед началом пары. Работает без интернета, по сохранённому расписанию. Выключить можно в настройках."
|
||
)
|
||
Section("За сколько минут") {
|
||
Picker("За сколько минут", selection: Binding(get: { model.settings.reminderMinutes }, set: { model.setReminderMinutes($0) })) {
|
||
ForEach([5, 10, 15, 30], id: \.self) { Text("\($0)").tag($0) }
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.listRowBackground(Color.clear)
|
||
.listRowInsets(EdgeInsets())
|
||
}
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
.safeAreaBar(edge: .bottom) {
|
||
VStack(spacing: 8) {
|
||
Button {
|
||
Task { onDone(await Reminders.authorize()) }
|
||
} label: {
|
||
Text("Включить напоминания").font(.headline).frame(maxWidth: .infinity).padding(.vertical, 6)
|
||
}
|
||
.buttonStyle(.glassProminent)
|
||
.controlSize(.large)
|
||
Button("Не сейчас") { onDone(false) }
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.bottom, 8)
|
||
.frame(maxWidth: 520)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The heading of a step page: its glass symbol, a title and what the choice changes.
|
||
private struct StepHeading: View {
|
||
var symbol: String
|
||
var title: String
|
||
var text: String
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Image(systemName: symbol)
|
||
.font(.title.weight(.semibold))
|
||
.foregroundStyle(Color.brand)
|
||
.frame(width: 72, height: 72)
|
||
.glassEffect(.regular.tint(Color.brand.opacity(0.18)), in: .circle)
|
||
Text(title).font(.largeTitle.bold()).accessibilityAddTraits(.isHeader)
|
||
Text(text).foregroundStyle(.secondary)
|
||
}
|
||
.listRowBackground(Color.clear)
|
||
.listRowInsets(EdgeInsets(top: 8, leading: 4, bottom: 8, trailing: 4))
|
||
}
|
||
}
|