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.
This commit is contained in:
566
ios/PSUSchedule/Views/HomeView.swift
Normal file
566
ios/PSUSchedule/Views/HomeView.swift
Normal file
@@ -0,0 +1,566 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The overview: this week at a glance, the session countdown, today's lessons,
|
||||
/// attendance, tasks and the next study day. On iPad the week grid sits beside it.
|
||||
struct HomeView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(Router.self) private var router
|
||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||
@State private var details: LessonRef?
|
||||
@State private var editor: EditorRequest?
|
||||
@State private var session = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
content
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Обзор")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { GroupButton() }
|
||||
ToolbarSpacer(.fixed, placement: .topBarTrailing)
|
||||
ToolbarItem(placement: .topBarTrailing) { SettingsButton() }
|
||||
}
|
||||
.refreshable { await model.refresh() }
|
||||
.navigationDestination(for: HomeRoute.self) { _ in SubjectsView() }
|
||||
}
|
||||
.sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) }
|
||||
.sheet(item: $editor) { NoteEditor(request: $0) }
|
||||
.sheet(isPresented: $session) { SessionSheet(exams: model.upcomingExams).presentationDetents([.medium, .large]) }
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
if let t = model.timetable {
|
||||
if t.semester == nil {
|
||||
VStack(spacing: 16) {
|
||||
Banner(text: t.note ?? "Даты для этого расписания не рассчитаны.", offline: false).padding(.horizontal)
|
||||
EmptyState(
|
||||
title: "Обзор недоступен",
|
||||
text: "Строки Word по дням недели — во вкладке «Расписание».",
|
||||
symbol: "doc.text",
|
||||
action: ("Открыть", { router.tab = .schedule })
|
||||
)
|
||||
}
|
||||
} else if sizeClass == .regular {
|
||||
// iPad: the overview beside this week's grid, as School Planner pairs them.
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
ScrollView { overview(t) }.frame(width: 420)
|
||||
WeekPane(timetable: t) { details = $0 }
|
||||
.background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 28, style: .continuous))
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
} else {
|
||||
ScrollView { overview(t).readableWidth() }
|
||||
}
|
||||
} else if model.status.loading {
|
||||
ProgressView().controlSize(.large).frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
VStack {
|
||||
StatusBanner().padding(.horizontal)
|
||||
EmptyState(
|
||||
title: "Расписание не загружено",
|
||||
text: "Выберите группу — обзор дня и недели появится здесь.",
|
||||
symbol: "person.2",
|
||||
action: model.settings.current != nil
|
||||
? ("Повторить", { Task { await model.refresh() } })
|
||||
: ("Выбрать группу", { router.groups = true })
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func overview(_ t: Timetable) -> some View {
|
||||
let today = model.today
|
||||
let todaySlots = model.slots(t, on: today)
|
||||
let next = (1...7).lazy.map { today.adding(days: $0) }.map { ($0, model.slots(t, on: $0)) }.first { !$0.1.isEmpty }
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
StatusBanner()
|
||||
WeekReport(timetable: t)
|
||||
SessionCard(timetable: t) { session = true }
|
||||
DayTitle(label: "Сегодня", date: today)
|
||||
TodayCard(slots: todaySlots, onOpen: { details = LessonRef(lesson: $0, date: today) }, onDay: { router.openDay(today) })
|
||||
AttendanceCard(slots: todaySlots)
|
||||
NavigationLink(value: HomeRoute.subjects) { SubjectsLink(timetable: t) }
|
||||
.buttonStyle(.plain)
|
||||
TasksCard(onEdit: { editor = EditorRequest(editing: $0) }, onAll: { router.tab = .plans })
|
||||
if let next {
|
||||
let (day, slots) = next
|
||||
DayTitle(label: day == today.adding(days: 1) ? "Завтра" : day.weekdayName, date: day)
|
||||
SectionCard(symbol: "calendar.badge.checkmark", title: pairs(slots.count)) {
|
||||
ForEach(slots) { slot in
|
||||
ForEach(slot.lessons, id: \.key) { lesson in
|
||||
Button { details = LessonRef(lesson: lesson, date: day) } label: { CompactLesson(lesson: lesson, slot: slot) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
DayButton { router.openDay(day) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.animation(.smooth, value: model.notes)
|
||||
.animation(.smooth, value: model.marks)
|
||||
}
|
||||
}
|
||||
|
||||
/// Screens the overview opens in its own stack.
|
||||
enum HomeRoute: Hashable {
|
||||
case subjects
|
||||
}
|
||||
|
||||
/// The way to the subjects: how many there are and how busy this week is.
|
||||
private struct SubjectsLink: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
|
||||
var body: some View {
|
||||
let subjects = Set((timetable.days.flatMap(\.lessons) + timetable.weekly.flatMap(\.lessons)).map { subjectKey($0.subject) }).count
|
||||
let monday = model.today.monday
|
||||
let week = (0..<7).reduce(0) { $0 + model.slots(timetable, on: monday.adding(days: $1)).count }
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "books.vertical")
|
||||
.font(.body.weight(.semibold))
|
||||
.foregroundStyle(Color.onBrand)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Color.brand, in: .circle)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Предметы").font(.headline)
|
||||
Text("\(subjects) \(plural(subjects, "предмет", "предмета", "предметов")) · на этой неделе \(pairs(week))")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
|
||||
}
|
||||
.card(padding: 16)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayTitle: View {
|
||||
var label: String
|
||||
var date: CivilDate
|
||||
var body: some View {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text(label).font(.title2.bold()).accessibilityAddTraits(.isHeader)
|
||||
Spacer()
|
||||
Text(date.longLabel).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayButton: View {
|
||||
var action: () -> Void
|
||||
var body: some View {
|
||||
Button(action: action) { Label("Открыть день", systemImage: "list.bullet.below.rectangle") }
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The current week at a glance: how many lessons are still ahead, a chip per day with its count
|
||||
* (past days ticked off, today highlighted) and the homework due this week. Once the week is
|
||||
* over it looks ahead to the next one.
|
||||
*/
|
||||
private struct WeekReport: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
|
||||
var body: some View {
|
||||
let t = timetable
|
||||
let date = model.today
|
||||
let clock = model.clock
|
||||
let monday = date.monday
|
||||
let days = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(t, on: $0)) }
|
||||
.filter { $0.0.weekday < 7 || !$0.1.isEmpty }
|
||||
let total = days.reduce(0) { $0 + $1.1.count }
|
||||
let left = days.reduce(0) { sum, day in
|
||||
sum + day.1.filter { $0.date > date || ($0.date == date && $0.end > clock) }.count
|
||||
}
|
||||
let week = t.day(monday)?.week
|
||||
let due = model.notes.filter { n in
|
||||
guard let d = CivilDate(n.date) else { return false }
|
||||
return n.task && !n.done && d >= date && d <= monday.adding(days: 6)
|
||||
}.count
|
||||
|
||||
SectionCard(symbol: "calendar", title: week.map { "Неделя \($0.n)" } ?? "Эта неделя") {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(headline(total: total, left: left)).font(.title3.bold())
|
||||
HStack(spacing: 12) {
|
||||
Text(weekSpan(monday, monday.adding(days: 6))).font(.subheadline).foregroundStyle(.secondary)
|
||||
if let week { WeekMark(week: week) }
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(days, id: \.0) { day in DayChip(date: day.0, count: day.1.count, today: date) }
|
||||
}
|
||||
}
|
||||
if due > 0 {
|
||||
Meta(symbol: "checklist", text: "Сдать до воскресенья: \(due) \(plural(due, "задание", "задания", "заданий"))")
|
||||
}
|
||||
if left == 0 {
|
||||
let next = monday.adding(days: 7)
|
||||
let nextTotal = (0..<7).reduce(0) { $0 + model.slots(t, on: next.adding(days: $1)).count }
|
||||
if nextTotal > 0 {
|
||||
let w = t.day(next)?.week
|
||||
Meta(symbol: "calendar.badge.checkmark", text: "Дальше " + (w.map { "неделя \($0.n), \($0.white ? "белая" : "зелёная"): " } ?? "") + pairs(nextTotal))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func headline(total: Int, left: Int) -> String {
|
||||
if total == 0 { return "На этой неделе пар нет" }
|
||||
if left == 0 { return "Пары этой недели закончились" }
|
||||
if left == total { return "Впереди " + pairs(total) }
|
||||
return "Осталось \(left) \(plural(left, "пара", "пары", "пар")) из \(total)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A weekday with its number of lessons: ticked off once past, strong today.
|
||||
private struct DayChip: View {
|
||||
var date: CivilDate
|
||||
var count: Int
|
||||
var today: CivilDate
|
||||
|
||||
var body: some View {
|
||||
let past = date < today
|
||||
let isToday = date == today
|
||||
VStack(spacing: 4) {
|
||||
Text(date.shortWeekday).font(.caption.weight(.medium)).foregroundStyle(isToday ? Color.brand : .secondary)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(count == 0 ? Color.clear : isToday ? Color.brand : past ? Color(.tertiarySystemFill) : Color.brand.opacity(0.15))
|
||||
Group {
|
||||
if count == 0 { Text("—") }
|
||||
else if past { Image(systemName: "checkmark").font(.footnote.weight(.bold)) }
|
||||
else { Text("\(count)").font(.subheadline.weight(.bold)) }
|
||||
}
|
||||
.foregroundStyle(isToday ? Color.onBrand : past || count == 0 ? .secondary : Color.brand)
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(date.weekdayName): " + (count == 0 ? "пар нет" : pairs(count) + (past ? ", прошли" : "")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The next lessons of today: the first in full with a countdown, the rest compact.
|
||||
private struct TodayCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var slots: [Slot]
|
||||
var onOpen: (Lesson) -> Void
|
||||
var onDay: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let clock = model.clock
|
||||
let left = slots.filter { $0.end > clock }
|
||||
SectionCard(symbol: "clock", title: "Следующие занятия") {
|
||||
if slots.isEmpty {
|
||||
Quiet(title: "Сегодня занятий нет", text: "Отдохните — ближайшие пары ниже.")
|
||||
} else if left.isEmpty {
|
||||
Quiet(title: "Пары на сегодня закончились", text: "Сегодня было \(pairs(slots.count)).")
|
||||
} else {
|
||||
let first = left[0]
|
||||
ForEach(first.lessons, id: \.key) { lesson in
|
||||
Button { onOpen(lesson) } label: { FeaturedLesson(lesson: lesson, slot: first, clock: clock) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if left.count > 1 { Divider() }
|
||||
ForEach(left.dropFirst()) { slot in
|
||||
ForEach(slot.lessons, id: \.key) { lesson in
|
||||
Button { onOpen(lesson) } label: { CompactLesson(lesson: lesson, slot: slot) }.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !slots.isEmpty { DayButton(action: onDay) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Quiet: View {
|
||||
var title: String
|
||||
var text: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.headline)
|
||||
Text(text).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The next lesson in full: subject, time with a countdown, room and teacher.
|
||||
private struct FeaturedLesson: View {
|
||||
@Environment(\.display) private var show
|
||||
var lesson: Lesson
|
||||
var slot: Slot
|
||||
var clock: ClockTime
|
||||
|
||||
var body: some View {
|
||||
let running = clock >= slot.start
|
||||
let minutes = (running ? slot.end : slot.start).minutes - clock.minutes
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
SubjectDot(subject: lesson.subject, size: 14).padding(.top, 6)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(lesson.subject).font(.title3.bold()).lineLimit(3).foregroundStyle(.primary)
|
||||
HStack(spacing: 10) {
|
||||
Text(slot.timeLabel).font(.body.monospacedDigit()).foregroundStyle(.secondary)
|
||||
Text(running ? "Идёт · ещё \(minutesLabel(minutes))" : "Через \(minutesLabel(minutes))")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 10).padding(.vertical, 4)
|
||||
.foregroundStyle(running ? Color.onBrand : Color.brand)
|
||||
.background(running ? Color.brand : Color.brand.opacity(0.14), in: .capsule)
|
||||
}
|
||||
if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") }
|
||||
else if show.rooms && !lesson.rooms.isEmpty { Meta(symbol: "mappin.and.ellipse", text: "Ауд. " + lesson.rooms.joined(separator: ", ")) }
|
||||
if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) }
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(.rect)
|
||||
}
|
||||
}
|
||||
|
||||
/// One-tap attendance for today's begun lessons without a mark; leaves once all are marked.
|
||||
private struct AttendanceCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var slots: [Slot]
|
||||
|
||||
var body: some View {
|
||||
let date = model.today
|
||||
let open = slots.filter { $0.start <= model.clock }
|
||||
.flatMap { slot in slot.lessons.map { (slot, $0) } }
|
||||
.filter { model.marks.forLesson($0.1.subject, date, $0.1.start) == nil }
|
||||
if model.groupKey != nil && !open.isEmpty {
|
||||
SectionCard(symbol: "person.badge.clock", title: "Отметьте посещаемость") {
|
||||
ForEach(open, id: \.1.key) { item in
|
||||
let (slot, lesson) = item
|
||||
HStack(spacing: 12) {
|
||||
SubjectDot(subject: lesson.subject, size: 10)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(lesson.subject).font(.subheadline.weight(.semibold)).lineLimit(2)
|
||||
Text(slot.timeLabel).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
GlassEffectContainer(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Button { model.mark(lesson, on: date, present: true) } label: { Image(systemName: "checkmark") }
|
||||
.buttonStyle(.glass)
|
||||
.tint(.green)
|
||||
.accessibilityLabel("Был на паре «\(lesson.subject)»")
|
||||
Button { model.mark(lesson, on: date, present: false) } label: { Image(systemName: "xmark") }
|
||||
.buttonStyle(.glass)
|
||||
.tint(.red)
|
||||
.accessibilityLabel("Пропустил пару «\(lesson.subject)»")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Homework and notes ahead: open tasks (overdue ones flagged) and notes for today and later,
|
||||
* soonest first. Ticked tasks stay struck through until their day has passed.
|
||||
*/
|
||||
private struct TasksCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var onEdit: (Note) -> Void
|
||||
var onAll: () -> Void
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
let date = model.today
|
||||
let pending = model.notes
|
||||
.filter { (CivilDate($0.date) ?? date) >= date || ($0.task && !$0.done) }
|
||||
.sorted { a, b in
|
||||
let ka = (a.task && a.done ? 1 : 0, a.date, a.start)
|
||||
let kb = (b.task && b.done ? 1 : 0, b.date, b.start)
|
||||
return ka < kb
|
||||
}
|
||||
if !pending.isEmpty {
|
||||
SectionCard(symbol: "checklist", title: "Задания и заметки", trailing: "\(pending.open.count)") {
|
||||
ForEach(expanded ? pending : Array(pending.prefix(4))) { note in
|
||||
NoteRow(note: note) { onEdit(note) }
|
||||
}
|
||||
HStack(spacing: 20) {
|
||||
if pending.count > 4 {
|
||||
Button(expanded ? "Свернуть" : "Показать все (\(pending.count))") { withAnimation(.smooth) { expanded.toggle() } }
|
||||
}
|
||||
Button("Все планы", action: onAll)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
/**
|
||||
* The session countdown: days to the next exam or credit, with the subject, date and room.
|
||||
* Before the university publishes the session it counts down to the end of the semester.
|
||||
*/
|
||||
private struct SessionCard: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var onOpen: () -> Void
|
||||
|
||||
var body: some View {
|
||||
let today = model.today
|
||||
let exams = model.upcomingExams
|
||||
let next = exams.first(where: \.counts) ?? exams.first
|
||||
let semesterEnd = timetable.semester.flatMap { CivilDate($0.end) }.flatMap { $0 >= today ? $0 : nil }
|
||||
if let target = next.flatMap({ CivilDate($0.date) }) ?? semesterEnd {
|
||||
let days = today.days(to: target)
|
||||
let card = HStack(spacing: 18) {
|
||||
Countdown(days: days)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(next.map { "Сессия · \($0.type.lowercased()) \(countdownLabel(days))" } ?? "До конца семестра \(countdownLabel(days).replacingOccurrences(of: "через ", with: ""))")
|
||||
.font(.subheadline.weight(.semibold)).foregroundStyle(Color.brand)
|
||||
if let next {
|
||||
Text(next.subject).font(.headline).lineLimit(2)
|
||||
Text(examWhen(next)).font(.subheadline).foregroundStyle(.secondary)
|
||||
if exams.count > 1 { Text("Ещё \(exams.count - 1) в сессии").font(.footnote).foregroundStyle(.secondary).padding(.top, 4) }
|
||||
} else {
|
||||
Text(timetable.semester?.label ?? "Семестр").font(.headline)
|
||||
Text("Учёба до \(semesterEnd!.longLabel). Даты экзаменов появятся здесь, когда университет опубликует расписание сессии.")
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.foregroundStyle(.primary)
|
||||
.card()
|
||||
// Only a published session opens a sheet; the semester countdown is just information.
|
||||
if next != nil {
|
||||
Button(action: onOpen) { card }.buttonStyle(.plain)
|
||||
} else {
|
||||
card
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The big number in a glass circle, tinted when the day is close.
|
||||
private struct Countdown: View {
|
||||
var days: Int
|
||||
var body: some View {
|
||||
let urgent = days <= 3
|
||||
VStack(spacing: 0) {
|
||||
Text(days == 0 ? "!" : "\(days)").font(.title.bold()).monospacedDigit()
|
||||
Text(days == 0 ? "сегодня" : days == 1 ? "день" : plural(days, "день", "дня", "дней")).font(.caption.weight(.medium))
|
||||
}
|
||||
.foregroundStyle(urgent ? Color.orange : Color.brand)
|
||||
.frame(width: 84, height: 84)
|
||||
.glassEffect(.regular.tint((urgent ? Color.orange : Color.brand).opacity(0.18)), in: .circle)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
func countdownLabel(_ days: Int) -> String {
|
||||
switch days {
|
||||
case 0: "сегодня"
|
||||
case 1: "завтра"
|
||||
default: "через \(days) \(plural(days, "день", "дня", "дней"))"
|
||||
}
|
||||
}
|
||||
|
||||
/// "Пн, 12 января, 09:00 · ауд. 213А".
|
||||
func examWhen(_ e: Exam) -> String {
|
||||
guard let d = CivilDate(e.date) else { return e.date }
|
||||
return d.shortWeekday + ", " + d.longLabel + ", " + e.start + (e.rooms.isEmpty ? "" : " · ауд. " + e.rooms.joined(separator: ", "))
|
||||
}
|
||||
|
||||
/// The whole session in date order, consultations included.
|
||||
struct SessionSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var exams: [Exam]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
let counted = exams.filter(\.counts).count
|
||||
Section {
|
||||
ForEach(exams, id: \.self) { e in
|
||||
let d = CivilDate(e.date) ?? .today()
|
||||
HStack(spacing: 16) {
|
||||
VStack {
|
||||
Text("\(d.day)").font(.title2.bold())
|
||||
Text(d.shortWeekday).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: 44)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(e.type + " · " + countdownLabel(CivilDate.today().days(to: d)))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(e.counts ? Color.brand : .secondary)
|
||||
Text(e.subject).font(.headline)
|
||||
Text([e.start + (e.end.map { "–\($0)" } ?? ""), e.rooms.isEmpty ? nil : "ауд. " + e.rooms.joined(separator: ", "), e.teachers.isEmpty ? nil : e.teachers.joined(separator: ", ")].compactMap { $0 }.joined(separator: " · "))
|
||||
.font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("\(counted) \(plural(counted, "экзамен или зачёт", "экзамена или зачёта", "экзаменов и зачётов"))" + (exams.count > counted ? ", консультаций: \(exams.count - counted)" : ""))
|
||||
} footer: {
|
||||
Text("Даты собраны из всех документов каталога по пометкам «экз.», «зач.» и «конс.». Сверяйтесь с документом Word.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Сессия")
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPad week pane
|
||||
|
||||
/// This week's grid beside the overview; on Sunday it already shows the coming week.
|
||||
private struct WeekPane: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var timetable: Timetable
|
||||
var onOpen: (LessonRef) -> Void
|
||||
@State private var monday: CivilDate?
|
||||
|
||||
var body: some View {
|
||||
let first = (model.today.weekday == 7 ? model.today.adding(days: 1) : model.today).monday
|
||||
let shown = monday ?? first
|
||||
let loaded = Set(timetable.dates)
|
||||
let week = timetable.day(shown)?.week
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(week.map { "Неделя \($0.n)" } ?? "Неделя").font(.headline)
|
||||
HStack(spacing: 10) {
|
||||
Text(weekSpan(shown, shown.adding(days: 6))).font(.subheadline).foregroundStyle(.secondary)
|
||||
if let week { WeekMark(week: week, short: true) }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
GlassEffectContainer(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
if shown != first {
|
||||
Button("Эта неделя", systemImage: "calendar.badge.clock") { withAnimation(.smooth) { monday = first } }
|
||||
.labelStyle(.iconOnly)
|
||||
}
|
||||
Button("Предыдущая неделя", systemImage: "chevron.left") { withAnimation(.smooth) { monday = shown.adding(days: -7) } }
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(!loaded.contains(shown.adding(days: -7)))
|
||||
Button("Следующая неделя", systemImage: "chevron.right") { withAnimation(.smooth) { monday = shown.adding(days: 7) } }
|
||||
.labelStyle(.iconOnly)
|
||||
.disabled(!loaded.contains(shown.adding(days: 7)))
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
WeekGrid(timetable: timetable, monday: shown, onOpen: onOpen)
|
||||
.id(shown)
|
||||
.transition(.push(from: .trailing))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user