Files
workouts/Workouts/Views/Today/ScheduleAddEditView.swift
T
rzen 7cb2d6da26 Show only the day's docket on the Today board and add ad hoc Now workouts
- Due-filter schedules: daily always, fixed days by weekday, one-offs on
  their date; rest days get an empty state
- Workouts with no due schedule row (ad hoc or off-day starts) now render
  as their own board rows
- The + sheet is now "New Workout" with a "When" picker; "Now" (offered
  when adding on today) skips the schedule and starts the workout directly
- Remove the times-per-week scheduling mode everywhere (enum, document,
  entity, mappers, planner, seeds, tests)
2026-07-11 11:09:11 -04:00

263 lines
9.5 KiB
Swift

//
// ScheduleAddEditView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
/// The UI-only "When" choice — a superset of `ScheduleRecurrence` with one extra
/// option, `.now`, that never gets persisted: it skips the schedule document
/// entirely and hands the routine straight back to the caller to start immediately.
private enum WhenChoice: String, CaseIterable, Identifiable {
case now, once, daily, fixedDays
var id: String { rawValue }
var displayName: String {
switch self {
case .now: "Now"
case .once: "Once"
case .daily: "Daily"
case .fixedDays: "Fixed Days"
}
}
/// The persisted recurrence this choice maps to, or nil for `.now` (never persisted).
var recurrence: ScheduleRecurrence? {
switch self {
case .now: nil
case .once: .once
case .daily: .daily
case .fixedDays: .fixedDays
}
}
init(recurrence: ScheduleRecurrence) {
switch recurrence {
case .once: self = .once
case .daily: self = .daily
case .fixedDays: self = .fixedDays
}
}
}
/// Add / edit a workout plan: pick a routine, tag it with a goal, and choose when it
/// happens — right now (unscheduled, adding only), once, daily, or on fixed days.
/// Fields are snapshotted in `init` so the sheet never holds the live entity.
struct ScheduleAddEditView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
/// nil id → add mode; a value → edit mode (id / order / createdAt preserved).
@State private var scheduleID: String?
@State private var order: Int
@State private var createdAt: Date
@State private var routineID: String
/// The schedule's routine name when it was created — surfaced when that routine
/// has since been deleted so the user knows what they're reassigning.
@State private var originalRoutineName: String
@State private var goal: GoalKind?
@State private var when: WhenChoice
@State private var weekdays: Set<Int>
@State private var date: Date
/// "Now" starts a workout immediately instead of saving a schedule — only offered
/// when adding (not editing) for today's default date, since a past/future day
/// can't be started "now".
private let offersNow: Bool
/// Called (after dismiss) when the user picks "Now" and taps Start Workout, with
/// the routine to start. The Today board starts it and navigates in.
private let onStartNow: (Routine) -> Void
/// Calendar weekday numbers in Monday-first display order (Mon…Sat = 2…7, Sun = 1).
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
/// Gregorian short names, 0-indexed from Sunday — weekday `n` is `symbols[n - 1]`.
private let weekdaySymbols = Calendar(identifier: .gregorian).shortWeekdaySymbols
/// `defaultDate` seeds the `.once` day when adding — the Today board passes the day
/// currently on screen, so "navigate to a day, tap +" plans a one-off for that day.
init(schedule: Schedule? = nil, defaultDate: Date = Date(),
onStartNow: @escaping (Routine) -> Void = { _ in }) {
_scheduleID = State(initialValue: schedule?.id)
_order = State(initialValue: schedule?.order ?? 0)
_createdAt = State(initialValue: schedule?.createdAt ?? Date())
_routineID = State(initialValue: schedule?.routineID ?? "")
_originalRoutineName = State(initialValue: schedule?.routineName ?? "")
_goal = State(initialValue: schedule?.goalKind)
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
_date = State(initialValue: schedule?.date ?? defaultDate)
self.onStartNow = onStartNow
let offersNow = schedule == nil && defaultDate.isSameDay(as: Date())
self.offersNow = offersNow
if let schedule {
_when = State(initialValue: WhenChoice(recurrence: schedule.recurrenceEnum))
} else {
_when = State(initialValue: offersNow ? .now : .once)
}
}
private var isEditing: Bool { scheduleID != nil }
/// The live routine currently selected; nil when the stored id no longer resolves.
private var selectedRoutine: Routine? { routines.first { $0.id == routineID } }
private var canSave: Bool {
guard selectedRoutine != nil else { return false }
if when == .fixedDays { return !weekdays.isEmpty }
return true
}
var body: some View {
NavigationStack {
Form {
routineSection
if when != .now {
goalSection
}
whenSection
}
.navigationTitle(isEditing ? "Edit Workout" : "New Workout")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(when == .now ? "Start Workout" : "Save", action: save)
.disabled(!canSave)
}
}
.onAppear {
// Default to the first routine when adding (the @Query isn't populated at init).
if routineID.isEmpty { routineID = routines.first?.id ?? "" }
}
}
}
// MARK: - Sections
private var routineSection: some View {
Section {
Picker("Routine", selection: $routineID) {
ForEach(routines) { routine in
Text(routine.name).tag(routine.id)
}
}
} header: {
Text("Routine")
} footer: {
if selectedRoutine == nil, !originalRoutineName.isEmpty {
Text("\"\(originalRoutineName)\" is no longer available. Choose a routine.")
}
}
}
private var goalSection: some View {
Section {
Picker("Goal", selection: $goal) {
Text("None").tag(GoalKind?.none)
ForEach(GoalKind.orderedCases()) { kind in
Label(kind.displayName, systemImage: kind.systemImage)
.foregroundStyle(Color.color(from: kind.colorName))
.tag(GoalKind?.some(kind))
}
}
.pickerStyle(.inline)
.labelsHidden()
} header: {
Text("Goal")
}
}
@ViewBuilder
private var whenSection: some View {
Section {
Picker("When", selection: $when) {
ForEach(WhenChoice.allCases) { choice in
if choice != .now || offersNow {
Text(choice.displayName).tag(choice)
}
}
}
switch when {
case .now, .daily:
EmptyView()
case .once:
DatePicker("Date", selection: $date, displayedComponents: .date)
case .fixedDays:
weekdayPicker
}
} header: {
Text("When")
}
}
/// A row of toggleable weekday chips, Monday-first.
private var weekdayPicker: some View {
HStack(spacing: 6) {
ForEach(weekdayOrder, id: \.self) { n in
let on = weekdays.contains(n)
Button {
if on { weekdays.remove(n) } else { weekdays.insert(n) }
} label: {
Text(weekdaySymbols[n - 1])
.font(.caption.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(on ? Color.accentColor : Color.secondary.opacity(0.15), in: Capsule())
.foregroundStyle(on ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
// MARK: - Save
private func save() {
guard let routine = selectedRoutine else { return }
// "Now" never persists a schedule — dismiss and hand the routine back so the
// Today board starts it immediately (mirroring its own start path).
guard let recurrence = when.recurrence else {
dismiss()
onStartNow(routine)
return
}
let doc = ScheduleDocument(
schemaVersion: ScheduleDocument.currentSchemaVersion,
id: scheduleID ?? ULID.make(),
routineID: routine.id,
routineName: routine.name,
goal: goal?.rawValue,
recurrence: recurrence.rawValue,
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
date: recurrence == .once ? date : nil,
order: isEditing ? order : nextOrder(),
createdAt: createdAt,
updatedAt: Date()
)
Task { await sync.save(schedule: doc) }
dismiss()
}
/// One past the highest existing schedule order — appends a new schedule to the end.
private func nextOrder() -> Int {
let existing = (try? modelContext.fetch(FetchDescriptor<Schedule>())) ?? []
return (existing.map(\.order).max() ?? -1) + 1
}
}