Files
psu-schedule/ios/PSUSchedule/Views/OnboardingView.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

307 lines
12 KiB
Swift
Raw Permalink 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
/// 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))
}
}