Files
psu-schedule/ios/PSUSchedule/Views/PlansView.swift
Zakhar Timoshenko 15851b26cd feat: Enhance session handling in timetable and UI
- Introduced session types (exams, credits, consultations, coursework defenses) with distinct icons and colors for better visibility in the UI.
- Updated PlansScreen, ScheduleScreen, and WeekGrid to display session information appropriately.
- Modified CalendarView and PlansView to include session indicators in the calendar and plans.
- Enhanced LessonCard and CompactLesson to show session details and adjust styling based on session presence.
- Added new SessionMark component for consistent session representation across Android and iOS.
- Updated server logic to handle session entries from external documents, ensuring accurate timetable representation.
- Improved date parsing to accommodate session-related dates in various formats.
- Added tests to verify session handling and date parsing functionality.
2026-10-19 20:49:13 +03:00

338 lines
16 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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])
}
}