- Parser 14 (server/places.js): "Занятия в Полоцке/Новополоцке/г. Новополоцк" over a day's first row is no longer a lesson but a note of the day for its column (`places`); lessons of that day get `place`. A lesson below the note in the same cell keeps its own subject.
- A town on its own line ("Новополоцк", "г.Новополоцк") or after a room ("246(Новополоцк)") becomes the lesson's `place` and leaves the subject and room. "История Полоцка" is not a town.
- API 14: `place` on lessons and `places` on days of /api/timetable, `place` in the directory. The day notes are shared by the server and the PWA (shared/places.js); the same note in every subgroup reads as the whole group's.
- Android, PWA and iOS: a "Занятия в Полоцке" card above the day's lessons (day pages, calendar, overview, week view, desk stand), "Полоцк" under the date in the week grid, "249Ж · Полоцк" on lesson cards, a "Город" row in details, the town in widgets, tile, reminders and change lists.
- Snapshot lines show the town and the day notes; tests for notes, towns in cells and the API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
585 lines
27 KiB
Swift
585 lines
27 KiB
Swift
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
|
||
@State private var changes: ScheduleChanges?
|
||
|
||
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]) }
|
||
.sheet(item: $changes) { ChangesSheet(record: $0).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()
|
||
// The university republished the document: what changed for the group, until seen.
|
||
if let record = model.changes, !record.seen {
|
||
ChangesCard(record: record, onShow: {
|
||
changes = record
|
||
model.markChangesSeen(record)
|
||
}, onClose: {
|
||
withAnimation { model.markChangesSeen(record) }
|
||
})
|
||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||
}
|
||
WeekReport(timetable: t)
|
||
SessionCard(timetable: t) { session = true }
|
||
DayTitle(label: "Сегодня", date: today)
|
||
TodayCard(slots: todaySlots, places: model.places(t, on: today), 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)) {
|
||
DayPlaces(places: model.places(t, on: day))
|
||
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 places: [DayPlace]
|
||
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 {
|
||
DayPlaces(places: places)
|
||
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) {
|
||
if let session = lesson.session { SessionBadge(kind: session) }
|
||
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 let room = withPlace(show.rooms && !lesson.rooms.isEmpty ? "Ауд. " + lesson.rooms.joined(separator: ", ") : nil, lesson.place) {
|
||
Meta(symbol: "mappin.and.ellipse", text: room)
|
||
}
|
||
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))
|
||
}
|
||
}
|
||
}
|