import SwiftUI /// A lesson opened in the details sheet; `date` is nil for Word rows without dates. struct LessonRef: Identifiable, Hashable { var lesson: Lesson var date: CivilDate? var id: String { (date?.iso ?? "-") + "|" + lesson.start + "|" + lesson.key } } /// A lesson as a soft card in its subject's colour, like School Planner's day list. struct LessonCard: View { @Environment(AppModel.self) private var model @Environment(\.display) private var show var lesson: Lesson var time: String? var date: CivilDate? var status: String? var running = false var body: some View { WithSubjectColor(subject: lesson.subject) { c in VStack(alignment: .leading, spacing: 6) { // Exams, credits and consultations announce themselves before the subject. if let session = lesson.session { SessionBadge(kind: session).padding(.bottom, 2) } HStack(alignment: .top, spacing: 8) { Text(lesson.subject).font(.headline).frame(maxWidth: .infinity, alignment: .leading) if let status { Text(status) .font(.footnote.weight(.semibold)) .padding(.horizontal, 8).padding(.vertical, 3) .foregroundStyle(running ? c.soft : c.onSoft) .background(running ? c.onSoft : c.onSoft.opacity(0.12), in: .rect(cornerRadius: 8, style: .continuous)) } } if let time { Text(time + (show.pairs ? lesson.pair.map { " · \($0) пара" } ?? "" : "")) .font(.body.monospacedDigit()) } FlowLayout(spacing: 14) { if lesson.distance { Meta(symbol: "mappin.and.ellipse", text: "Дистанционно") } else if let room = withPlace( show.rooms && !lesson.rooms.isEmpty ? lesson.rooms.joined(separator: ", ") + (lesson.roomMoved ? " (перенос)" : "") : nil, lesson.place ) { Meta(symbol: "mappin.and.ellipse", text: room) } if show.teachers && !lesson.teachers.isEmpty { Meta(symbol: "person", text: lesson.teachers.joined(separator: ", ")) } if let sub = lesson.subgroup { Meta(symbol: "person.2", text: sub) } } let mark = date.flatMap { model.marks.forLesson(lesson.subject, $0, lesson.start) } let tags = show.tags ? tagsOf(lesson) : [] if !tags.isEmpty || lesson.uncertain || mark != nil { FlowLayout(spacing: 6) { if let mark { Tag(text: mark.present ? "был" : "пропуск", symbol: mark.present ? "checkmark" : "xmark", foreground: c.onSoft, background: c.onSoft.opacity(0.12)) } ForEach(tags, id: \.self) { Tag(text: $0, foreground: c.onSoft, background: c.onSoft.opacity(0.12)) } if lesson.uncertain { Tag(text: "Сверьте недели", foreground: .red, background: .red.opacity(0.14)) } } .padding(.top, 4) } if show.notes && !lesson.condition.isEmpty { Meta(symbol: "repeat", text: "Пометка в Word: \(lesson.condition)") } if let date { NotesHint(notes: model.notes.forLesson(lesson.subject, date, lesson.start)) } } .foregroundStyle(c.onSoft) .padding(.horizontal, 18) .padding(.vertical, 14) .frame(maxWidth: .infinity, alignment: .leading) .background(c.soft, in: .rect(cornerRadius: 22, style: .continuous)) .overlay { if running { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(c.onSoft, lineWidth: 2) } else if lesson.session != nil { RoundedRectangle(cornerRadius: 22, style: .continuous).strokeBorder(Color.sessionAccent, lineWidth: 2) } } .contentShape(.rect(cornerRadius: 22)) } } } func tagsOf(_ lesson: Lesson) -> [String] { var tags: [String] = [] if !lesson.type.isEmpty && lesson.session == nil { tags.append(lesson.type) } if lesson.variant != "all" { tags.append(lesson.variant == "upper" ? "белые недели" : "зелёные недели") } if lesson.groups.count > 1 { tags.append("общее занятие") } return tags } /// A one-line hint on a lesson card: the first open task or note and how many more. struct NotesHint: View { var notes: [Note] var body: some View { let open = notes.open if let first = open.first { Label { Text(first.text + (open.count > 1 ? " +\(open.count - 1)" : "")).lineLimit(1) } icon: { Image(systemName: first.task ? "checklist" : "note.text") } .font(.subheadline.weight(.semibold)) .padding(.top, 2) } } } /// Later lessons in a card: a coloured dot, the subject and "13:40 → 15:05 · ауд. 254В". struct CompactLesson: View { @Environment(\.display) private var show var lesson: Lesson var slot: Slot var body: some View { HStack(spacing: lesson.session == nil ? 14 : 10) { if let session = lesson.session { SessionDot(kind: session, size: 22) } else { SubjectDot(subject: lesson.subject, size: 10) } VStack(alignment: .leading, spacing: 2) { Text(lesson.subject).font(.subheadline.weight(.semibold)).lineLimit(2).foregroundStyle(.primary) Text(details).font(.subheadline.monospacedDigit()).foregroundStyle(lesson.session == nil ? Color.secondary : Color.sessionAccent) } Spacer(minLength: 0) } .contentShape(.rect) } private var details: String { var parts = [slot.timeLabel] if let session = lesson.session { parts.insert(session.label, at: 0) } if let sub = lesson.subgroup, lesson.session != nil { parts.append(sub.lowercased().replacingOccurrences(of: "подгруппа", with: "подгр.")) } if lesson.distance { parts.append("дистанционно") } else if let room = withPlace(show.rooms && !lesson.rooms.isEmpty ? "ауд. " + lesson.rooms.joined(separator: ", ") : nil, lesson.place) { parts.append(room) } if show.tags && !lesson.type.isEmpty && lesson.session == nil { parts.append(lesson.type.lowercased()) } return parts.joined(separator: " · ") } } // MARK: - Details /// Everything about one lesson: time, teacher, room, attendance, tasks and the Word record. struct LessonDetails: View { @Environment(AppModel.self) private var model @Environment(\.dismiss) private var dismiss var ref: LessonRef @State private var editor: EditorRequest? var body: some View { let lesson = ref.lesson let bell = model.timetable?.bells.first { $0.start == lesson.start } NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 18) { TypeTag(type: lesson.type) HStack(alignment: .firstTextBaseline, spacing: 12) { SubjectDot(subject: lesson.subject, size: 14) Text(lesson.subject).font(.title2.bold()).accessibilityAddTraits(.isHeader) } VStack(alignment: .leading, spacing: 14) { DetailRow( symbol: "clock", label: bell.map { "\($0.pair) пара" } ?? "Время", value: lesson.start + (lesson.end.map { " — \($0)" } ?? ""), extra: bell.flatMap { $0.halves.count > 1 ? $0.halves.map { $0.joined(separator: "–") }.joined(separator: ", ") : nil } ) DetailRow(symbol: "person", label: "Преподаватель", value: lesson.teachers.isEmpty ? "Не указан" : lesson.teachers.joined(separator: ", ")) DetailRow( symbol: "mappin.and.ellipse", label: "Аудитория", value: lesson.distance ? "Дистанционно" : (lesson.rooms.isEmpty ? "Не указана" : lesson.rooms.joined(separator: ", ")), extra: lesson.roomMoved ? "В этот день — перенос аудитории" : nil ) if let place = lesson.place { DetailRow(symbol: "building.2", label: "Город", value: place, extra: "Так указано в Word") } DetailRow(symbol: "person.3", label: "Группы", value: lesson.groups.joined(separator: ", "), extra: lesson.subgroup) } .card() if lesson.uncertain { Banner(text: "Пометку «\(lesson.condition)» не удалось сопоставить с таблицей недель. Сверьте с Word.", offline: false) } if let date = ref.date, model.groupKey != nil { if model.started(date, lesson.start) { AttendancePicker(lesson: lesson, date: date) } LessonNotes(lesson: lesson, date: date, editor: $editor) } VStack(alignment: .leading, spacing: 8) { Text("Исходная запись").font(.headline) Text(lesson.raw) .font(.body) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(16) .background(Color(.tertiarySystemFill), in: .rect(cornerRadius: 18, style: .continuous)) } if model.groupKey != nil { VStack(alignment: .leading, spacing: 8) { Button(role: .destructive) { model.setHidden(lesson.hideKey, true) dismiss() } label: { Label("Скрыть такие занятия", systemImage: "eye.slash") } .buttonStyle(.glass) Text("«\(lesson.subject)»\(lesson.type.isEmpty ? "" : " · " + lesson.type.lowercased()) пропадёт из расписания, обзора, виджетов и напоминаний во все недели. Вернуть можно в настройках.") .font(.footnote) .foregroundStyle(.secondary) } } } .padding(20) } .background(Color(.systemGroupedBackground)) .navigationTitle(ref.date.map { $0.weekdayName + ", " + $0.longLabel } ?? "Занятие") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } } } .sheet(item: $editor) { NoteEditor(request: $0) } } } private struct TypeTag: View { var type: String var body: some View { let color: Color = switch type { case "Лекция": .brand case "Практика": .purple case "Экзамен", "Зачёт", "Защита курсовой работы", "Консультация": .sessionAccent default: .gray } Text(type.isEmpty ? "Занятие" : type) .font(.subheadline.weight(.semibold)) .padding(.horizontal, 10).padding(.vertical, 4) .foregroundStyle(color) .background(color.opacity(0.14), in: .capsule) } } private struct DetailRow: View { var symbol: String var label: String var value: String var extra: String? var body: some View { HStack(alignment: .top, spacing: 14) { Image(systemName: symbol).foregroundStyle(.secondary).frame(width: 22) VStack(alignment: .leading, spacing: 2) { Text(label).font(.subheadline).foregroundStyle(.secondary) Text(value).font(.body.weight(.medium)) if let extra { Text(extra).font(.subheadline).foregroundStyle(.secondary) } } } .accessibilityElement(children: .combine) } } /// "Был" / "Пропустил"; choosing the marked answer again takes the mark back. private struct AttendancePicker: View { @Environment(AppModel.self) private var model var lesson: Lesson var date: CivilDate var body: some View { let mark = model.marks.forLesson(lesson.subject, date, lesson.start) VStack(alignment: .leading, spacing: 8) { Text("Посещаемость").font(.headline) GlassEffectContainer(spacing: 8) { HStack(spacing: 8) { choice("Был", "checkmark", value: true, current: mark?.present) choice("Пропустил", "xmark", value: false, current: mark?.present) } } if mark != nil { Text("Нажмите на отметку ещё раз, чтобы снять её.").font(.footnote).foregroundStyle(.secondary) } } } private func choice(_ label: String, _ symbol: String, value: Bool, current: Bool?) -> some View { let on = current == value return Button { model.mark(lesson, on: date, present: on ? nil : value) } label: { Label(label, systemImage: on ? symbol : "circle").frame(maxWidth: .infinity) } .buttonStyle(.glass) .tint(on ? (value ? .green : .red) : nil) .accessibilityAddTraits(on ? .isSelected : []) } } // MARK: - Tasks and notes /// What the editor needs to file a new note: the lesson it opened from and the next one. struct NoteTarget: Hashable { var subject: String var date: CivilDate var start: String var next: NextLesson? } struct NextLesson: Hashable { var date: CivilDate var start: String } /// Opens the editor for a new note (target) or an existing one (editing). struct EditorRequest: Identifiable { var target: NoteTarget? var editing: Note? var id: String { editing?.id ?? "new-" + (target.map { $0.date.iso + $0.start + $0.subject } ?? "") } } /// The lesson sheet's section: this lesson's tasks and notes and a button to add one. private struct LessonNotes: View { @Environment(AppModel.self) private var model var lesson: Lesson var date: CivilDate @Binding var editor: EditorRequest? var body: some View { let notes = model.notes.forLesson(lesson.subject, date, lesson.start) VStack(alignment: .leading, spacing: 6) { Text("Задания и заметки").font(.headline) if notes.isEmpty { Text("Запишите домашнее задание или напоминание — оно появится в карточке пары и в обзоре.") .font(.subheadline) .foregroundStyle(.secondary) } ForEach(notes) { note in NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) } } Button { let next = model.nextOccurrence(of: lesson.subject, after: date, start: lesson.start) editor = EditorRequest(target: NoteTarget( subject: lesson.subject, date: date, start: lesson.start, next: next.map { NextLesson(date: $0.0, start: $0.1.start) } )) } label: { Label("Добавить", systemImage: "plus") } .buttonStyle(.glass) .padding(.top, 6) } } } /// A task with its checkbox or a note with its icon; a tap opens the editor. struct NoteRow: View { @Environment(AppModel.self) private var model var note: Note var showSubject = true var showDue = true var onEdit: () -> Void var body: some View { let overdue = note.overdue(today: model.today) HStack(alignment: .center, spacing: 12) { if note.task { Button { withAnimation(.snappy) { model.toggle(note) } } label: { Image(systemName: note.done ? "checkmark.circle.fill" : "circle") .font(.title2) .foregroundStyle(note.done ? Color.brand : Color.secondary) .contentTransition(.symbolEffect(.replace)) } .buttonStyle(.plain) .accessibilityLabel(note.done ? "Выполнено" : "Не выполнено") } else { Image(systemName: "note.text").font(.title3).foregroundStyle(.secondary).frame(width: 28) } Button(action: onEdit) { VStack(alignment: .leading, spacing: 2) { Text(note.text) .strikethrough(note.task && note.done) .foregroundStyle(note.task && note.done ? .secondary : .primary) .lineLimit(3) HStack(spacing: 6) { if showSubject && !note.general { SubjectDot(subject: note.subject, size: 8) } Text(subtitle(overdue: overdue)) .font(.subheadline) .foregroundStyle(overdue ? Color.red : Color.secondary) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) } .buttonStyle(.plain) } .padding(.vertical, 4) } private func subtitle(overdue: Bool) -> String { var parts: [String] = [] if showSubject { parts.append(note.general ? "Общий план" : note.subject) } if overdue { parts.append("просрочено") } else if showDue { parts.append(note.dueLabel(today: model.today)) } else if !note.start.isEmpty { parts.append("к " + note.start) } return parts.joined(separator: " · ") } } /** * Adds or edits a task or a note. A new one goes to this lesson or, for homework set in * class, to the next lesson of the subject; once the lesson is over the next one is the default. */ struct NoteEditor: View { @Environment(AppModel.self) private var model @Environment(\.dismiss) private var dismiss var request: EditorRequest @State private var text = "" @State private var task = true @State private var toNext = false @State private var day = Date() @FocusState private var focused: Bool private var general: Bool { request.editing?.general ?? (request.target?.subject.trimmed.isEmpty ?? true) } var body: some View { NavigationStack { Form { Section { Picker("Вид", selection: $task) { Text("Задание").tag(true) Text("Заметка").tag(false) } .pickerStyle(.segmented) .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) } Section { TextField(task ? "Например, сдать лабораторную 3" : "Например, принести конспект", text: $text, axis: .vertical) .lineLimit(3...8) .focused($focused) .textInputAutocapitalization(.sentences) } if general { Section("День") { DatePicker("День", selection: $day, displayedComponents: .date) .environment(\.timeZone, minsk) } } if request.editing == nil, let target = request.target, let next = target.next { Section { Picker("К какому занятию", selection: $toNext) { Text("Эта пара").tag(false) Text("Следующая").tag(true) } .pickerStyle(.segmented) } header: { Text("К какому занятию") } footer: { let (d, start) = toNext ? (next.date, next.start) : (target.date, target.start) Text(d.shortWeekday + ", " + d.longLabel + ", " + start) } } if let editing = request.editing { Section { Button("Удалить", role: .destructive) { model.delete(editing) dismiss() } } } } .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Отмена", systemImage: "xmark") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Сохранить", systemImage: "checkmark") { save() } .disabled(text.trimmed.isEmpty) } } } .presentationDetents([.medium, .large]) .onAppear { if let e = request.editing { text = e.text task = e.task day = (CivilDate(e.date) ?? model.today).date } else if let t = request.target { day = t.date.date // Once the lesson has begun, homework most likely belongs to the next one. toNext = t.next != nil && model.started(t.date, t.start) && !t.start.isEmpty } focused = true } } private var title: String { if request.editing != nil { return task ? "Задание" : "Заметка" } if general { return task ? "Новый общий план" : "Новая заметка на день" } return task ? "Новое задание" : "Новая заметка" } private func save() { guard let group = request.editing?.group ?? model.groupKey else { return } var note: Note if let e = request.editing { note = e } else if let t = request.target { let (d, start) = toNext && t.next != nil ? (t.next!.date, t.next!.start) : (t.date, t.start) note = Note(group: group, subject: t.subject, date: d.iso, start: start, text: "") } else { return } if general { note.date = CivilDate(day).iso } note.text = text.trimmed note.task = task model.save(note) dismiss() } }