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)) } }