import SwiftUI /** * The month with marks on days with lessons and with plans, green weeks highlighted; * below it, the chosen day's plans and lessons. */ struct CalendarView: View { @Environment(AppModel.self) private var model @Environment(\.horizontalSizeClass) private var sizeClass @State private var month: CivilDate? @State private var selected: CivilDate? @State private var details: LessonRef? @State private var editor: EditorRequest? @State private var adding = false @State private var forward = true var body: some View { let date = selected ?? model.today let shown = month ?? date.firstOfMonth NavigationStack { Group { if sizeClass == .regular { HStack(alignment: .top, spacing: 16) { ScrollView { monthCard(shown, date).padding(16) }.frame(width: 460) ScrollView { agenda(date).padding(16) } .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 28, style: .continuous)) .padding([.trailing, .bottom], 16) } } else { ScrollView { VStack(spacing: 16) { monthCard(shown, date) agenda(date) } .padding(16) .padding(.bottom, 60) .readableWidth() } } } .background(Color(.systemGroupedBackground)) .navigationTitle("Планы") .navigationSubtitle(shown.monthTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { if date != model.today || shown != model.today.firstOfMonth { ToolbarItem(placement: .topBarLeading) { Button("Сегодня", systemImage: "calendar.badge.clock") { withAnimation(.smooth) { forward = model.today > date selected = model.today month = model.today.firstOfMonth } } } } ToolbarItem(placement: .topBarTrailing) { PlansModeButton() } } .overlay(alignment: .bottomTrailing) { Button { adding = true } label: { Image(systemName: "plus").font(.title2.weight(.semibold)).frame(width: 36, height: 36) } .buttonStyle(.glassProminent) .buttonBorderShape(.circle) .controlSize(.large) .padding(20) .accessibilityLabel("Новый план") } } .sheet(isPresented: $adding) { AddPlanSheet(day: date) { target in adding = false editor = EditorRequest(target: target) } } .sheet(item: $editor) { NoteEditor(request: $0) } .sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) } } private func monthCard(_ shown: CivilDate, _ date: CivilDate) -> some View { VStack(spacing: 12) { HStack { Text(shown.monthTitle).font(.title3.bold()) Spacer() GlassEffectContainer(spacing: 8) { HStack(spacing: 8) { Button("Предыдущий месяц", systemImage: "chevron.left") { go(shown.adding(months: -1)) } Button("Следующий месяц", systemImage: "chevron.right") { go(shown.adding(months: 1)) } } .labelStyle(.iconOnly) .buttonStyle(.glass) } } MonthGrid(month: shown, selected: date) { d in withAnimation(.smooth) { forward = d > date selected = d if d.firstOfMonth != shown { month = d.firstOfMonth } } } .id(shown) .transition(.push(from: forward ? .trailing : .leading)) .gesture( DragGesture(minimumDistance: 30).onEnded { g in if abs(g.translation.width) > abs(g.translation.height) { go(shown.adding(months: g.translation.width < 0 ? 1 : -1)) } } ) } .card(padding: 16) .clipped() } private func go(_ target: CivilDate) { withAnimation(.smooth) { forward = target > (month ?? model.today.firstOfMonth) month = target.firstOfMonth } } @ViewBuilder private func agenda(_ date: CivilDate) -> some View { DayAgenda(date: date, details: $details, editor: $editor) .id(date) .transition(.push(from: forward ? .trailing : .leading)) } } private struct MonthGrid: View { @Environment(AppModel.self) private var model var month: CivilDate var selected: CivilDate var onSelect: (CivilDate) -> Void var body: some View { let first = month.firstOfMonth.monday let next = month.adding(months: 1).firstOfMonth let rows = stride(from: 0, to: 42, by: 7).map { first.adding(days: $0) }.filter { $0 < next } VStack(spacing: 4) { HStack(spacing: 0) { ForEach(["п", "в", "с", "ч", "п", "с", "в"].indices, id: \.self) { i in Text(["П", "В", "С", "Ч", "П", "С", "В"][i]).font(.caption.weight(.semibold)).foregroundStyle(.secondary).frame(maxWidth: .infinity) } } .accessibilityHidden(true) ForEach(rows, id: \.self) { monday in let week = model.timetable?.day(monday)?.week HStack(spacing: 0) { ForEach(0..<7, id: \.self) { i in cell(monday.adding(days: i)) } } .background { // The university's green weeks, as on the week strips. if let week, !week.white { RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color.greenWeek.opacity(0.16)) } } } } } private func cell(_ d: CivilDate) -> some View { let inMonth = d.month == month.month let isSelected = d == selected let isToday = d == model.today let lessons = model.timetable.map { !model.slots($0, on: d).isEmpty } ?? false let plans = model.notes.contains { $0.date == d.iso } let color: Color = isSelected ? .onBrand : isToday ? .brand : inMonth ? .primary : Color(.tertiaryLabel) return Button { onSelect(d) } label: { VStack(spacing: 3) { Text("\(d.day)") .font(.callout.weight(isToday || isSelected ? .bold : .regular)) .foregroundStyle(color) .frame(width: 34, height: 34) .background { if isSelected { Circle().fill(Color.brand) } else if isToday { Circle().strokeBorder(Color.brand, lineWidth: 1.5) } } HStack(spacing: 3) { Circle().fill(lessons ? Color.secondary : .clear).frame(width: 4, height: 4) if plans { Circle().fill(Color.orange).frame(width: 4, height: 4) } } .frame(height: 4) } .frame(maxWidth: .infinity) .padding(.vertical, 2) .contentShape(.rect) } .buttonStyle(.plain) .accessibilityLabel(d.weekdayName + ", " + d.longLabel + (lessons ? ", есть пары" : "") + (plans ? ", есть планы" : "")) .accessibilityAddTraits(isSelected ? .isSelected : []) } } /// The chosen day: its plans first, then its lessons on a timeline. private struct DayAgenda: View { @Environment(AppModel.self) private var model var date: CivilDate @Binding var details: LessonRef? @Binding var editor: EditorRequest? var body: some View { let slots = model.timetable.map { model.slots($0, on: date) } ?? [] let notes = model.notes.filter { $0.date == date.iso } VStack(alignment: .leading, spacing: 14) { HStack(alignment: .lastTextBaseline) { Text(date == model.today ? "Сегодня" : date.weekdayName).font(.title2.bold()) Spacer() Text(date.longLabel).font(.subheadline).foregroundStyle(.secondary) if let week = model.timetable?.day(date)?.week { WeekMark(week: week, short: true) } } if slots.isEmpty && notes.isEmpty { let outside = model.timetable.map { t in t.semester.map { date.iso < $0.start || date.iso > $0.end } ?? true } ?? true EmptyState( title: model.timetable == nil ? "Расписание не загружено" : "Свободный день", text: outside && model.timetable != nil ? "Эта дата вне текущего семестра. Планов на \(date.longLabel) нет." : "\(date.weekdayName), \(date.longLabel): ни пар, ни планов.", symbol: "sofa" ) } if !notes.isEmpty { Text("Ваши планы").font(.headline) VStack(spacing: 4) { ForEach(notes) { note in NoteRow(note: note, showDue: false) { editor = EditorRequest(editing: note) } } } .card(padding: 14) } if !slots.isEmpty { Text("Ваши занятия").font(.headline) ForEach(slots) { slot in ForEach(slot.lessons, id: \.key) { lesson in Button { details = LessonRef(lesson: lesson, date: date) } label: { LessonCard(lesson: lesson, time: slot.timeLabel, date: date) } .buttonStyle(.plain) } } } } } }