import SwiftUI /** * The dated timetable: days page horizontally under a floating Liquid Glass strip of the week, * tinted green on the university's green weeks; the week view is a time grid. */ struct ScheduleView: View { @Environment(AppModel.self) private var model @Environment(Router.self) private var router @Environment(\.horizontalSizeClass) private var sizeClass @State private var details: LessonRef? var body: some View { NavigationStack { Group { if let t = model.timetable, t.semester != nil, !t.days.isEmpty { Planner(timetable: t, details: $details) } else if let t = model.timetable { WordRows(timetable: t) { details = LessonRef(lesson: $0, date: nil) } .navigationTitle(t.group) } else { EmptyState( title: "Расписание не загружено", text: "Проверьте подключение или выберите группу заново.", symbol: "icloud.slash", action: model.settings.current != nil ? ("Повторить", { Task { await model.refresh() } }) : ("Выбрать группу", { router.groups = true }) ) .navigationTitle("Расписание") } } .background(Color(.systemGroupedBackground)) } .sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) } } } private struct Planner: View { @Environment(AppModel.self) private var model @Environment(Router.self) private var router @Environment(\.horizontalSizeClass) private var sizeClass var timetable: Timetable @Binding var details: LessonRef? @State private var selected: CivilDate? @State private var gridMonday: CivilDate? var body: some View { let t = timetable let dates = t.dates // On Sunday the next study day is more useful than an empty page. let start = model.today.weekday == 7 ? model.today.adding(days: 1) : model.today let fallback = dates.contains(start) ? start : (dates.first { $0 >= start } ?? dates.last!) let date = selected ?? fallback let weekView = router.weekView ?? (sizeClass == .regular) let monday = weekView ? (gridMonday ?? date.monday) : date.monday let week = t.day(monday)?.week let onToday = weekView ? monday == fallback.monday : date == fallback Group { if weekView { WeekPager(timetable: t, monday: Binding(get: { monday }, set: { gridMonday = $0 })) { details = $0 } .transition(.opacity) } else { // A bar, not a plain inset: it blurs the cards scrolling under the title and the strip. DayPager(timetable: t, dates: dates, selection: Binding(get: { date }, set: { selected = $0 })) { details = $0 } .safeAreaBar(edge: .top, spacing: 0) { WeekStrip(timetable: t, selected: date, week: week) { selected = $0 } .padding(.horizontal, 12) .padding(.bottom, 6) .readableWidth() } .transition(.opacity) } } .animation(.smooth(duration: 0.35), value: weekView) .navigationTitle(weekTitle(monday.adding(days: 3), week?.n)) .navigationSubtitle([t.group, model.subgroup?.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр."), week.map { $0.white ? "белая неделя" : "зелёная неделя" }].compactMap { $0 }.joined(separator: " · ")) .navigationBarTitleDisplayMode(.inline) .toolbar { if !onToday { ToolbarItem(placement: .topBarLeading) { Button("Сегодня", systemImage: "calendar.badge.clock") { withAnimation(.smooth) { if weekView { gridMonday = fallback.monday } else { selected = fallback } } } } } ToolbarItemGroup(placement: .topBarTrailing) { Button(weekView ? "Показать день" : "Показать неделю", systemImage: weekView ? "list.bullet.below.rectangle" : "calendar") { if !weekView { gridMonday = date.monday } router.weekView = !weekView } .contentTransition(.symbolEffect(.replace)) Button("Обновить", systemImage: "arrow.clockwise") { Task { await model.refresh() } } .disabled(model.status.loading) } } .onChange(of: router.focus, initial: true) { _, focus in // A day or week opened from elsewhere: the overview, the calendar, a widget. guard let focus else { return } if dates.contains(focus) { selected = focus } gridMonday = focus.monday router.focus = nil } } } /// The days of the semester, one page each, swiped sideways. private struct DayPager: View { @Environment(AppModel.self) private var model var timetable: Timetable var dates: [CivilDate] @Binding var selection: CivilDate var onOpen: (LessonRef) -> Void var body: some View { // A paging scroll view rather than a page-style TabView: each day's list is an ordinary // scroll view, so it starts below the week strip, ends above the tab bar and scrolls // under their glass. ScrollView(.horizontal) { LazyHStack(spacing: 0) { ForEach(dates) { d in DayPage(timetable: timetable, date: d, onOpen: onOpen) .containerRelativeFrame(.horizontal) .id(d) } } .scrollTargetLayout() } .scrollTargetBehavior(.paging) .scrollIndicators(.hidden) .scrollPosition(id: Binding(get: { selection }, set: { if let d = $0 { selection = d } })) } } private struct DayPage: View { @Environment(AppModel.self) private var model var timetable: Timetable var date: CivilDate var onOpen: (LessonRef) -> Void var body: some View { let slots = model.slots(timetable, on: date) let clock = model.clock let isToday = date == model.today let next = isToday ? slots.first { $0.start > clock } : nil ScrollView { LazyVStack(spacing: 12) { StatusBanner() if slots.isEmpty { EmptyState(title: "Занятий нет", text: "По таблице недель в этот день свободно — можно отдохнуть.", symbol: "sofa") .padding(.top, 60) } DayPlaces(places: model.places(timetable, on: date)) ForEach(slots) { slot in let running = isToday && clock >= slot.start && clock < slot.end let status: String? = running ? "Идёт · ещё \(minutesLabel(slot.end.minutes - clock.minutes))" : (slot == next && slot.start.minutes - clock.minutes <= 120 ? "Через \(minutesLabel(slot.start.minutes - clock.minutes))" : nil) ForEach(slot.lessons, id: \.key) { lesson in Button { onOpen(LessonRef(lesson: lesson, date: date)) } label: { LessonCard(lesson: lesson, time: slot.timeLabel, date: date, status: status, running: running) } .buttonStyle(.plain) } } } .padding(16) .readableWidth() .animation(.smooth, value: slots) } .refreshable { await model.refresh() } } } /** * The week as a floating glass strip: weekday, date and a dot on days with lessons. Swipe * it or use the arrows for the neighbouring weeks; green weeks tint the glass. */ private struct WeekStrip: View { @Environment(AppModel.self) private var model var timetable: Timetable var selected: CivilDate var week: Week? var onSelect: (CivilDate) -> Void var body: some View { let monday = selected.monday let loaded = Set(timetable.dates) HStack(spacing: 2) { Button("Предыдущая неделя", systemImage: "chevron.left") { step(-1, loaded) } .labelStyle(.iconOnly) .frame(width: 30) ForEach(0..<7, id: \.self) { i in let d = monday.adding(days: i) let isSelected = d == selected let has = !model.slots(timetable, on: d).isEmpty Button { if loaded.contains(d) { onSelect(d) } } label: { VStack(spacing: 3) { Text(d.shortWeekday).font(.caption2.weight(.medium)) .foregroundStyle(d == model.today ? Color.brand : .secondary) Text("\(d.day)") .font(.callout.weight(isSelected ? .bold : .regular)) .foregroundStyle(isSelected ? Color.onBrand : .primary) .frame(width: 32, height: 32) .background { if isSelected { Circle().fill(Color.brand) } } Circle().fill(has ? Color.secondary : .clear).frame(width: 4, height: 4) } .frame(maxWidth: .infinity) .contentShape(.rect) } .buttonStyle(.plain) .disabled(!loaded.contains(d)) .accessibilityLabel(d.weekdayName + ", " + d.longLabel + (has ? "" : ", пар нет")) .accessibilityAddTraits(isSelected ? .isSelected : []) } Button("Следующая неделя", systemImage: "chevron.right") { step(1, loaded) } .labelStyle(.iconOnly) .frame(width: 30) } .padding(.vertical, 8) .padding(.horizontal, 4) .glassEffect(.regular.tint(weekTint(week)).interactive(), in: .rect(cornerRadius: 26, style: .continuous)) .gesture( DragGesture(minimumDistance: 24).onEnded { g in if abs(g.translation.width) > abs(g.translation.height) { step(g.translation.width < 0 ? 1 : -1, loaded) } } ) .animation(.smooth, value: selected) } /// The same weekday in the neighbouring week, or the nearest loaded day. private func step(_ direction: Int, _ loaded: Set) { let target = selected.adding(days: 7 * direction) if loaded.contains(target) { onSelect(target); return } let sorted = loaded.sorted() if let near = sorted.min(by: { abs($0.days(to: target)) < abs($1.days(to: target)) }), near != selected { onSelect(near) } } } /// The week grid; a sideways swipe moves to the neighbouring loaded week. private struct WeekPager: View { var timetable: Timetable @Binding var monday: CivilDate var onOpen: (LessonRef) -> Void @State private var forward = true var body: some View { let mondays = Set(timetable.dates.map(\.monday)) WeekGrid(timetable: timetable, monday: monday, onOpen: onOpen) .padding(.top, 8) .id(monday) .transition(.push(from: forward ? .trailing : .leading)) .simultaneousGesture( DragGesture(minimumDistance: 30).onEnded { g in guard abs(g.translation.width) > abs(g.translation.height) * 1.5 else { return } let step = g.translation.width < 0 ? 7 : -7 let next = monday.adding(days: step) guard mondays.contains(next) else { return } forward = step > 0 withAnimation(.smooth) { monday = next } } ) } } /// When the week table has no semester for this schedule: the Word rows by weekday. private struct WordRows: View { @Environment(AppModel.self) private var model var timetable: Timetable var onOpen: (Lesson) -> Void var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 14) { StatusBanner() Banner(text: timetable.note ?? "Даты не рассчитаны: показаны строки Word.", offline: false) ForEach(timetable.weekly, id: \.day) { day in Text(dayName(day.day)).font(.title3.bold()).padding(.top, 8) let lessons = day.lessons .filter { model.subgroup == nil || $0.subgroup == nil || $0.subgroup == model.subgroup } .filter { !model.hidden.contains($0.hideKey) } if !lessons.isEmpty { DayPlaces(places: day.places.forSubgroup(model.subgroup)) } ForEach(lessons, id: \.key) { lesson in Button { onOpen(lesson) } label: { LessonCard(lesson: lesson, time: lesson.start + (lesson.end.map { " → \($0)" } ?? ""), date: nil) } .buttonStyle(.plain) } } } .padding(16) .readableWidth() } .refreshable { await model.refresh() } } }