Files
psu-schedule/ios/PSUSchedule/Views/PlansView.swift
Zakhar Timoshenko 170a44c377 Add date and time handling, models, schedule management, storage, and widget support
- Introduced `Dates.swift` for handling date and time in the university's timezone.
- Created `Models.swift` to define data structures for timetable, lessons, and directory search results.
- Implemented `Schedule.swift` for managing lesson slots and timetable functionalities.
- Developed `Storage.swift` for user settings and data persistence.
- Added `Widgets.swift` to create widgets displaying lessons and upcoming classes.
2026-10-18 20:26:12 +03:00

332 lines
16 KiB
Swift
Raw 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 {
let slots = prefs.lessons ? model.timetable.map { model.slots($0, on: d) } ?? [] : []
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.orange : .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])
}
}