import Charts import SwiftUI /// Everything the loaded weeks say about one subject. private struct SubjectSummary: Identifiable { var name: String var types: [String] var teachers: [String] var rooms: [String] var upcoming: [(Slot, Lesson)] var weekDone: Int var weekTotal: Int var id: String { subjectKey(name) } } private func unique(_ items: [String]) -> [String] { var seen = Set() return items.filter { seen.insert($0).inserted } } /// Subjects soonest first, with the week's progress, teachers and rooms. struct SubjectsView: View { @Environment(AppModel.self) private var model @Environment(Router.self) private var router @State private var open: String? var body: some View { Group { if let t = model.timetable, t.semester != nil { let subjects = summaries(t) ScrollView { VStack(alignment: .leading, spacing: 16) { WeekLoad(timetable: t, subjects: subjects.count) HStack(alignment: .lastTextBaseline) { Text("Все предметы").font(.title2.bold()) Spacer() Text("по ближайшему занятию").font(.subheadline).foregroundStyle(.secondary) } .padding(.top, 8) LazyVGrid(columns: [GridItem(.adaptive(minimum: 320), spacing: 12, alignment: .top)], spacing: 12) { ForEach(subjects) { s in Button { open = s.id } label: { SubjectCard(summary: s) }.buttonStyle(.plain) } } } .padding(16) .frame(maxWidth: 1200) .frame(maxWidth: .infinity) } .sheet(item: Binding(get: { open.flatMap { id in subjects.first { $0.id == id } } }, set: { open = $0?.id })) { s in SubjectDetails(summary: s).presentationDetents([.medium, .large]) } } else if model.timetable != nil { EmptyState(title: "Предметы недоступны", text: "Для этого расписания даты не рассчитаны: откройте вкладку «Расписание».", symbol: "calendar.badge.exclamationmark") } else { EmptyState(title: "Предметов пока нет", text: "Выберите группу — предметы появятся из её расписания.", symbol: "books.vertical", action: ("Выбрать группу", { router.groups = true })) } } .background(Color(.systemGroupedBackground)) .navigationTitle("Предметы") } private func summaries(_ t: Timetable) -> [SubjectSummary] { let date = model.today let clock = model.clock let monday = date.monday let entries = t.dates.flatMap { model.slots(t, on: $0) }.flatMap { slot in slot.lessons.map { (slot, $0) } } let grouped = Dictionary(grouping: entries) { subjectKey($0.1.subject) } return grouped.values.map { items in let lessons = items.map(\.1) let week = items.filter { $0.0.date.monday == monday } return SubjectSummary( name: lessons[0].subject, types: unique(lessons.map { $0.type.isEmpty ? "Занятие" : $0.type }), teachers: unique(lessons.flatMap(\.teachers)), rooms: unique(lessons.flatMap(\.rooms)), upcoming: items.filter { $0.0.date > date || ($0.0.date == date && $0.0.end > clock) }, weekDone: week.filter { $0.0.date < date || ($0.0.date == date && $0.0.end <= clock) }.count, weekTotal: week.count ) } // Soonest first; subjects with nothing ahead go to the end. .sorted { a, b in switch (a.upcoming.first?.0, b.upcoming.first?.0) { case let (x?, y?): (x.date, x.start, a.name) < (y.date, y.start, b.name) case (.some, nil): true case (nil, .some): false default: a.name < b.name } } } } /// Pairs per weekday of the current week as a bar chart, today's bar in the strong tone. private struct WeekLoad: View { @Environment(AppModel.self) private var model var timetable: Timetable var subjects: Int var body: some View { let date = model.today let monday = date.monday let days = (0..<7).map { monday.adding(days: $0) }.map { ($0, model.slots(timetable, on: $0).count) } .filter { $0.0.weekday < 7 || $0.1 > 0 } let total = days.reduce(0) { $0 + $1.1 } let most = days.map(\.1).max() ?? 0 let busiest = days.filter { $0.1 == most && most > 0 }.map { $0.0.weekdayName.lowercased() } SectionCard(symbol: "chart.bar", title: "Эта неделя") { VStack(alignment: .leading, spacing: 2) { Text("\(pairs(total)) · \(subjects) \(plural(subjects, "предмет", "предмета", "предметов"))").font(.title3.bold()) if !busiest.isEmpty { Text("Больше всего — \(busiest.joined(separator: " и ")), \(pairs(most))").font(.subheadline).foregroundStyle(.secondary) } } Chart(days, id: \.0) { day in BarMark(x: .value("День", day.0.shortWeekday), y: .value("Пары", day.1), width: .ratio(0.55)) .foregroundStyle(day.0 == date ? Color.brand : Color.brand.opacity(day.0 < date ? 0.3 : 0.55)) .clipShape(.rect(cornerRadius: 6)) .annotation(position: .top) { Text(day.1 > 0 ? "\(day.1)" : "—").font(.caption.weight(.semibold)).foregroundStyle(day.0 == date ? Color.brand : .secondary) } } .chartYAxis(.hidden) .chartYScale(domain: 0...max(1, most + 1)) .frame(height: 120) .accessibilityLabel(days.map { "\($0.0.weekdayName): \($0.1)" }.joined(separator: ", ")) } } } private struct SubjectCard: View { @Environment(AppModel.self) private var model var summary: SubjectSummary var body: some View { WithSubjectColor(subject: summary.name) { c in VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 12) { Text(abbreviate(summary.name)) .font(.footnote.weight(.bold)) .lineLimit(1) .minimumScaleFactor(0.6) .padding(.horizontal, 4) .foregroundStyle(c.onStrong) .frame(width: 44, height: 44) .background(c.strong, in: .rect(cornerRadius: 12, style: .continuous)) VStack(alignment: .leading, spacing: 2) { Text(summary.name).font(.headline).lineLimit(3) Text(summary.types.joined(separator: ", ")).font(.subheadline).foregroundStyle(.secondary) } } if let first = summary.upcoming.first { let (slot, lesson) = first Meta(symbol: "clock", text: nextLabel(slot) + (withPlace(lesson.rooms.isEmpty ? nil : "ауд. " + lesson.rooms.joined(separator: ", "), lesson.place).map { " · " + $0 } ?? "")) } else { Meta(symbol: "checkmark.circle", text: "Занятий в этом семестре больше нет") } if !summary.teachers.isEmpty { Meta(symbol: "person", text: summary.teachers.joined(separator: ", ")) } if summary.weekTotal > 0 { ProgressView(value: Double(summary.weekDone), total: Double(summary.weekTotal)) { Text("На этой неделе: \(summary.weekDone) из \(summary.weekTotal)").font(.subheadline) } .tint(c.strong) } let stats = model.marks.stats(for: summary.name) if stats.total > 0 { Meta(symbol: "person.badge.clock", text: "Посещено \(stats.present) из \(stats.total)") } let tasks = model.notes.filter { subjectKey($0.subject) == summary.id && $0.task && !$0.done }.count if tasks > 0 { Meta(symbol: "checklist", text: "\(tasks) \(plural(tasks, "задание", "задания", "заданий"))") } } .foregroundStyle(.primary) .card(padding: 18) } } private func nextLabel(_ slot: Slot) -> String { let today = model.today let day = slot.date == today ? "сегодня" : slot.date == today.adding(days: 1) ? "завтра" : slot.date.shortWeekday.lowercased() + ", " + slot.date.longLabel return "Далее \(day), \(slot.start)" } } /// A subject in full: teachers, rooms, upcoming lessons, attendance and tasks. private struct SubjectDetails: View { @Environment(AppModel.self) private var model @Environment(\.dismiss) private var dismiss var summary: SubjectSummary @State private var details: LessonRef? @State private var editor: EditorRequest? var body: some View { NavigationStack { List { Section { LabeledContent("Виды занятий", value: summary.types.joined(separator: ", ")) if !summary.teachers.isEmpty { LabeledContent("Преподаватели", value: summary.teachers.joined(separator: ", ")) } if !summary.rooms.isEmpty { LabeledContent("Аудитории", value: summary.rooms.joined(separator: ", ")) } let stats = model.marks.stats(for: summary.name) if stats.total > 0 { LabeledContent("Посещаемость", value: "\(stats.present) из \(stats.total)") } } let notes = model.notes.filter { subjectKey($0.subject) == summary.id } if !notes.isEmpty { Section("Задания и заметки") { ForEach(notes) { note in NoteRow(note: note, showSubject: false) { editor = EditorRequest(editing: note) } } } } Section("Ближайшие занятия") { if summary.upcoming.isEmpty { Text("Занятий в этом семестре больше нет").foregroundStyle(.secondary) } ForEach(Array(summary.upcoming.prefix(12).enumerated()), id: \.offset) { _, item in Button { details = LessonRef(lesson: item.1, date: item.0.date) } label: { VStack(alignment: .leading, spacing: 2) { Text(item.0.date.weekdayName + ", " + item.0.date.longLabel).font(.subheadline.weight(.semibold)) Text([item.0.timeLabel, item.1.type.isEmpty ? nil : item.1.type.lowercased(), withPlace(item.1.rooms.isEmpty ? nil : "ауд. " + item.1.rooms.joined(separator: ", "), item.1.place)].compactMap { $0 }.joined(separator: " · ")) .font(.subheadline).foregroundStyle(.secondary) } } .foregroundStyle(.primary) } } } .navigationTitle(summary.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Готово", systemImage: "checkmark") { dismiss() } } } } .sheet(item: $details) { LessonDetails(ref: $0).presentationDetents([.medium, .large]) } .sheet(item: $editor) { NoteEditor(request: $0) } } }