- 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.
338 lines
16 KiB
Swift
338 lines
16 KiB
Swift
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])
|
||
}
|
||
}
|