import SwiftUI /// "Сегодня • Суббота • 17 октября", "Завтра • …" or "Понедельник • 19 октября". func planDayTitle(_ d: CivilDate, today: CivilDate) -> String { if d == today { return "Сегодня • \(d.weekdayName) • \(d.longLabel)" } if d == today.adding(days: 1) { return "Завтра • \(d.weekdayName) • \(d.longLabel)" } return "\(d.weekdayName) • \(d.longLabel)" } /// One day of the plans: its lessons (when shown) and its tasks and notes. private struct PlanDay: Identifiable { var date: CivilDate? // nil: overdue var slots: [Slot] var notes: [Note] var id: String { date?.iso ?? "overdue" } var empty: Bool { slots.isEmpty && notes.isEmpty } } /** * Plans: what has to be done, day by day — homework and notes pinned to lessons, overdue tasks * first. Three layouts as in School Planner: an agenda with a week strip, a compact list of * busy days, and a board of day columns. */ struct PlansView: View { @Environment(AppModel.self) private var model @State private var editor: EditorRequest? @State private var adding = false @State private var details: LessonRef? @State private var day: CivilDate? var body: some View { let prefs = model.settings.plans let days = planDays(prefs) let open = model.notes.filter { ($0.task && !$0.done) || (!$0.task && (CivilDate($0.date) ?? model.today) >= model.today) }.count NavigationStack { Group { if days.allSatisfy(\.empty) && prefs.layout != .agenda { EmptyState(title: "Планов нет", text: "Задания и заметки к парам появятся здесь. Добавьте первое кнопкой «+».", symbol: "checklist") } else { switch prefs.layout { case .board: Board(days: days, editor: $editor, details: $details) case .agenda, .list: Agenda(days: prefs.layout == .agenda ? days : days.filter { !$0.empty }, strip: prefs.layout == .agenda, editor: $editor, details: $details, day: $day) } } } .animation(.smooth, value: prefs) .background(Color(.systemGroupedBackground)) .navigationTitle("Планы") .navigationSubtitle(open == 0 ? "Всё сделано" : "\(open) \(plural(open, "дело", "дела", "дел")) впереди") // A compact title, as in the schedule: a large one collapsing above the floating week // strip jumps when the list comes back to the top. .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { PlansModeButton() } ToolbarSpacer(.fixed, placement: .topBarTrailing) ToolbarItem(placement: .topBarTrailing) { PlansMenu() } } .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: nil) { 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 planDays(_ prefs: PlansPrefs) -> [PlanDay] { let today = model.today let notes = prefs.done ? model.notes : model.notes.filter { !($0.task && $0.done) } let last = model.timetable?.days.last.flatMap { CivilDate($0.date) }.flatMap { $0 >= today ? $0 : nil } ?? today.adding(days: 13) let overdue = notes.filter { $0.overdue(today: today) }.sorted { ($0.date, $0.start) < ($1.date, $1.start) } let byDay = Dictionary(grouping: notes.filter { (CivilDate($0.date) ?? today) >= today }, by: \.date) var out: [PlanDay] = [] if !overdue.isEmpty { out.append(PlanDay(date: nil, slots: [], notes: overdue)) } var d = today while d <= last { // Exams, credits and consultations are deadlines too: they stay even with "Пары дня" off. let all = model.timetable.map { model.slots($0, on: d) } ?? [] let slots = prefs.lessons ? all : all.compactMap { slot -> Slot? in var s = slot s.lessons = slot.lessons.filter { $0.session != nil } return s.lessons.isEmpty ? nil : s } let list = (byDay[d.iso] ?? []).sorted { (($0.task && $0.done) ? 1 : 0, $0.start) < (($1.task && $1.done) ? 1 : 0, $1.start) } out.append(PlanDay(date: d, slots: slots, notes: list)) d = d.adding(days: 1) } return out } } /// The layout and what the list shows, as School Planner's plan settings. private struct PlansMenu: View { @Environment(AppModel.self) private var model var body: some View { let prefs = model.settings.plans Menu { Picker("Макет", selection: Binding(get: { prefs.layout }, set: { var p = prefs; p.layout = $0; model.setPlans(p) })) { Label("Повестка дня", systemImage: "list.bullet.rectangle").tag(PlansLayout.agenda) Label("Список", systemImage: "list.bullet").tag(PlansLayout.list) Label("Доска", systemImage: "rectangle.split.3x1").tag(PlansLayout.board) } .pickerStyle(.inline) Section { Toggle("Пары дня", systemImage: "calendar.day.timeline.left", isOn: Binding(get: { prefs.lessons }, set: { var p = prefs; p.lessons = $0; model.setPlans(p) })) Toggle("Выполненные", systemImage: "checkmark.circle", isOn: Binding(get: { prefs.done }, set: { var p = prefs; p.done = $0; model.setPlans(p) })) } footer: { Text(footer(prefs.layout)) } } label: { Label("Вид планов", systemImage: "slider.horizontal.3") } } private func footer(_ layout: PlansLayout) -> String { switch layout { case .agenda: "Все дни подряд с полосой недели — видно и занятые, и свободные дни." case .list: "Без полосы недели и без пустых дней — только дни с парами или планами." case .board: "Колонка на каждый день, листаются в сторону." } } } /// The agenda (every day, with a week strip) and the list (busy days only), with sticky days. private struct Agenda: View { @Environment(AppModel.self) private var model var days: [PlanDay] var strip: Bool @Binding var editor: EditorRequest? @Binding var details: LessonRef? @Binding var day: CivilDate? var body: some View { ScrollViewReader { proxy in List { ForEach(days) { d in Section { if !d.slots.isEmpty, let date = d.date { LessonsSummary(slots: d.slots) { details = LessonRef(lesson: $0, date: date) } } ForEach(d.notes) { note in NoteRow(note: note, showDue: d.date == nil) { editor = EditorRequest(editing: note) } .swipeActions { Button("Удалить", systemImage: "trash", role: .destructive) { model.delete(note) } } } if d.empty { Text("Ничего не запланировано").font(.subheadline).foregroundStyle(.tertiary) } } header: { Text(d.date.map { planDayTitle($0, today: model.today) } ?? "Просрочено") .font(.subheadline.weight(.semibold)) .foregroundStyle(d.date == nil ? Color.red : d.date == model.today ? Color.brand : .secondary) .textCase(nil) } .id(d.id) } Color.clear.frame(height: 60).listRowBackground(Color.clear) } .listStyle(.insetGrouped) .animation(.smooth, value: days.map(\.notes)) .safeAreaBar(edge: .top, spacing: 0) { if strip { PlanStrip(selected: day ?? model.today) { d in day = d withAnimation(.smooth) { proxy.scrollTo(d.iso, anchor: .top) } } .padding(.horizontal, 12) .padding(.bottom, 6) .readableWidth() } } } } } /// The week of the agenda: a dot under days with plans, glass tinted on green weeks. private struct PlanStrip: View { @Environment(AppModel.self) private var model var selected: CivilDate var onSelect: (CivilDate) -> Void @State private var monday: CivilDate? var body: some View { let shown = monday ?? selected.monday let week = model.timetable?.day(shown)?.week HStack(spacing: 2) { Button("Предыдущая неделя", systemImage: "chevron.left") { monday = max(model.today.monday, shown.adding(days: -7)) } .labelStyle(.iconOnly).frame(width: 30) .disabled(shown <= model.today.monday) ForEach(0..<7, id: \.self) { i in let d = shown.adding(days: i) let isSelected = d == selected let has = model.notes.contains { $0.date == d.iso } Button { if d >= model.today { 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 : d < model.today ? .secondary : .primary) .frame(width: 32, height: 32) .background { if isSelected { Circle().fill(Color.brand) } } Circle().fill(has ? Color.purple : .clear).frame(width: 5, height: 5) } .frame(maxWidth: .infinity) .contentShape(.rect) } .buttonStyle(.plain) .disabled(d < model.today) } Button("Следующая неделя", systemImage: "chevron.right") { monday = shown.adding(days: 7) } .labelStyle(.iconOnly).frame(width: 30) } .padding(.vertical, 8) .padding(.horizontal, 4) .glassEffect(.regular.tint(weekTint(week)).interactive(), in: .rect(cornerRadius: 26, style: .continuous)) } } /// The day's lessons in one quiet row, above its plans. private struct LessonsSummary: View { var slots: [Slot] var onOpen: (Lesson) -> Void var body: some View { VStack(alignment: .leading, spacing: 8) { ForEach(slots) { slot in ForEach(slot.lessons, id: \.key) { lesson in Button { onOpen(lesson) } label: { CompactLesson(lesson: lesson, slot: slot) }.buttonStyle(.plain) } } } .padding(.vertical, 4) } } /// A column per day, scrolled sideways. private struct Board: View { @Environment(AppModel.self) private var model var days: [PlanDay] @Binding var editor: EditorRequest? @Binding var details: LessonRef? var body: some View { ScrollView(.horizontal) { LazyHStack(alignment: .top, spacing: 12) { ForEach(days) { d in VStack(alignment: .leading, spacing: 10) { Text(d.date.map { "\($0.longLabel) • \($0.weekdayName)" + ($0 == model.today ? " • Сегодня" : "") } ?? "Просрочено") .font(.subheadline.weight(.semibold)) .foregroundStyle(d.date == nil ? Color.red : d.date == model.today ? Color.brand : .secondary) ScrollView { VStack(alignment: .leading, spacing: 8) { if !d.slots.isEmpty, let date = d.date { LessonsSummary(slots: d.slots) { details = LessonRef(lesson: $0, date: date) } Divider() } ForEach(d.notes) { note in NoteRow(note: note, showDue: d.date == nil) { editor = EditorRequest(editing: note) } } if d.empty { Text("Ничего не запланировано").font(.subheadline).foregroundStyle(.tertiary) } } } } .padding(16) .frame(width: 290, alignment: .topLeading) .frame(maxHeight: .infinity, alignment: .top) .background(Color(.secondarySystemGroupedBackground), in: .rect(cornerRadius: 26, style: .continuous)) } } .scrollTargetLayout() .padding(16) } .scrollTargetBehavior(.viewAligned) } } /// A new plan: a general one for any day, or pinned to a lesson of the next two weeks. struct AddPlanSheet: View { @Environment(AppModel.self) private var model @Environment(\.dismiss) private var dismiss var day: CivilDate? var onPick: (NoteTarget) -> Void var body: some View { NavigationStack { List { Section { Button { onPick(NoteTarget(subject: "", date: day ?? model.today, start: "", next: nil)) } label: { Label { VStack(alignment: .leading) { Text("Общий план").font(.headline) Text("Без привязки к паре — на любой день").font(.subheadline).foregroundStyle(.secondary) } } icon: { Image(systemName: "calendar.badge.plus") } } .foregroundStyle(.primary) } if let t = model.timetable { let days = (day.map { [$0] } ?? (0..<14).map { model.today.adding(days: $0) }) .map { ($0, model.slots(t, on: $0)) } .filter { !$0.1.isEmpty } ForEach(days, id: \.0) { item in let (d, slots) = item Section(planDayTitle(d, today: model.today)) { ForEach(slots) { slot in ForEach(slot.lessons, id: \.key) { l in Button { onPick(NoteTarget(subject: l.subject, date: d, start: l.start, next: nil)) } label: { CompactLesson(lesson: l, slot: slot) } .foregroundStyle(.primary) } } } } } } .navigationTitle("Новый план") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Отмена", systemImage: "xmark") { dismiss() } } } } .presentationDetents([.medium, .large]) } }