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

133 lines
5.3 KiB
Swift

import SwiftUI
/// Three sections and search: the calendar lives inside the plans, the subjects inside the overview.
enum AppTab: Hashable {
case home, schedule, plans, search
}
/// Where the screens send the student: another tab, a day of the schedule, the groups or settings.
@MainActor
@Observable
final class Router {
var tab: AppTab = .home
var groups = false
var settings = false
/** A day or week the schedule should show next (from the overview, calendar, widgets). */
var focus: CivilDate?
/** The schedule as a week grid instead of days; iPad starts with the grid. */
var weekView: Bool?
/** The plans as a month calendar instead of a list. */
var plansCalendar = false
/** What the search tab looks for: its field lives in the tab bar. */
var searchQuery = ""
func openDay(_ date: CivilDate) {
focus = date
weekView = false
tab = .schedule
}
func openWeek(_ date: CivilDate) {
focus = date
weekView = true
tab = .schedule
}
}
struct RootView: View {
@Environment(AppModel.self) private var model
// Decided once: picking the group mid-onboarding must not cut the remaining steps short.
@State private var onboarding = Storage.loadSettings().current == nil
@State private var router = Router()
var body: some View {
ZStack {
if onboarding {
OnboardingView { withAnimation(.smooth(duration: 0.5)) { onboarding = false } }
.transition(.opacity.combined(with: .scale(scale: 1.04)))
} else {
MainTabs()
.transition(.opacity)
}
}
.environment(router)
.onOpenURL { url in
// A widget tapped: psuschedule://day/2026-10-19 opens that day.
if url.host() == "day", let date = CivilDate(url.lastPathComponent) { router.openDay(date) }
else if url.host() == "day" { router.openDay(model.today) }
}
.environment(\.palette, Palette(slots: model.timetable?.subjectSlots ?? [:], enabled: model.settings.display.colors))
.environment(\.display, model.settings.display)
}
}
/// The sections in the Liquid Glass tab bar; on iPad they become a sidebar.
struct MainTabs: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
var body: some View {
@Bindable var router = router
TabView(selection: $router.tab) {
Tab("Обзор", systemImage: "house", value: AppTab.home) { HomeView() }
Tab("Расписание", systemImage: "calendar.day.timeline.left", value: AppTab.schedule) { ScheduleView() }
Tab("Планы", systemImage: "checklist", value: AppTab.plans) { PlansTab() }
Tab("Поиск", systemImage: "magnifyingglass", value: AppTab.search, role: .search) {
SearchView().searchable(text: $router.searchQuery, prompt: "Фамилия или номер аудитории")
}
}
// Search stands apart from the three sections as its own glass button; tapping it turns
// the button into the field above the keyboard, as in Apple's own apps. The field belongs
// to the search tab only, so the other sections keep their bars clean.
.tabViewSearchActivation(.searchTabSelection)
.tabViewStyle(.sidebarAdaptable)
.tabBarMinimizeBehavior(.onScrollDown)
.sheet(isPresented: $router.groups) {
NavigationStack { GroupsView(onboarding: false) { router.groups = false } }
}
.sheet(isPresented: $router.settings) { SettingsView() }
}
}
/// The open group as a glass capsule in the toolbar: one tap from switching.
struct GroupButton: View {
@Environment(AppModel.self) private var model
@Environment(Router.self) private var router
var body: some View {
let group = model.timetable?.group ?? model.settings.current?.group
Button { router.groups = true } label: {
Label(group ?? "Группа", systemImage: "person.2.fill").labelStyle(.titleAndIcon)
}
.accessibilityLabel(group.map { "Группа \($0), сменить" } ?? "Выбрать группу")
}
}
/// The plans tab: the list of plans or the month calendar, switched from the toolbar.
struct PlansTab: View {
@Environment(Router.self) private var router
var body: some View {
ZStack {
if router.plansCalendar { CalendarView().transition(.opacity) } else { PlansView().transition(.opacity) }
}
.animation(.smooth(duration: 0.3), value: router.plansCalendar)
}
}
/// Switches the plans between the list and the calendar, as the schedule switches day and week.
struct PlansModeButton: View {
@Environment(Router.self) private var router
var body: some View {
Button(router.plansCalendar ? "Показать список" : "Показать календарь", systemImage: router.plansCalendar ? "list.bullet" : "calendar") {
router.plansCalendar.toggle()
}
.contentTransition(.symbolEffect(.replace))
}
}
struct SettingsButton: View {
@Environment(Router.self) private var router
var body: some View {
Button("Настройки", systemImage: "gearshape") { router.settings = true }
}
}