Files
workouts/Workouts/Views/Today/ScheduleAddEditView.swift
T
rzen 1a1147b612 Resolve the seed redirect when editing a schedule's routine
A schedule made against a starter-seed routine keeps the retired seed id
after the routine is edited (clone-on-edit). The edit form matched that id
directly, so the routine read as unselected and Save stayed disabled; it
now resolves through sync.currentRoutineID like the Today board does, and
the routine picker's checkmark compares against the resolved id.
2026-07-11 12:40:18 -04:00

377 lines
14 KiB
Swift

//
// ScheduleAddEditView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
import UserNotifications
/// 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
@State private var reminderEnabled: Bool
@State private var reminderTime: Date
/// Notification permission has been explicitly denied — surfaced as a footer so
/// the user knows reminders won't arrive until re-enabled in Settings. Checked
/// async on appear; never blocks the form.
@State private var notificationsDenied = false
/// "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)
_reminderEnabled = State(initialValue: schedule?.reminderMinutes != nil)
_reminderTime = State(initialValue: Self.time(fromMinutes: schedule?.reminderMinutes))
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, following the seed clone-on-edit redirect
/// (a schedule made against a starter seed keeps the retired seed id after the
/// routine is edited); nil when the routine has been deleted.
private var selectedRoutine: Routine? {
guard !routineID.isEmpty else { return nil }
let liveID = sync.currentRoutineID(for: routineID)
return routines.first { $0.id == liveID }
}
/// The When choices this presentation offers — "Now" only when adding on today.
/// Built as a list (rather than conditionally hiding a tag inside the Picker)
/// so the segmented control renders a clean 3- or 4-segment strip.
private var availableChoices: [WhenChoice] {
offersNow ? WhenChoice.allCases : WhenChoice.allCases.filter { $0 != .now }
}
private var canSave: Bool {
guard selectedRoutine != nil else { return false }
if when == .fixedDays { return !weekdays.isEmpty }
return true
}
var body: some View {
NavigationStack {
Form {
whenSection
routineSection
if when != .now {
goalSection
reminderSection
}
}
.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 ?? "" }
}
.task {
// Surface a denied permission as a footer note — informational only.
let settings = await UNUserNotificationCenter.current().notificationSettings()
notificationsDenied = settings.authorizationStatus == .denied
}
}
}
// MARK: - Sections
private var routineSection: some View {
Section {
NavigationLink {
RoutinePickerList(routines: routines, currentID: selectedRoutine?.id,
selectedID: $routineID)
} label: {
HStack {
Text("Routine")
Spacer()
Text(selectedRoutine?.name ?? "Choose…")
.foregroundStyle(.secondary)
}
}
} 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) {
// A hidden symbol placeholder keeps "None" aligned with the
// symbol-bearing goal rows below it.
Label {
Text("None")
} icon: {
Image(systemName: "tag").hidden()
}
.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(availableChoices) { choice in
Text(choice.displayName).tag(choice)
}
}
.pickerStyle(.segmented)
switch when {
case .now, .daily:
EmptyView()
case .once:
DatePicker("Date", selection: $date, displayedComponents: .date)
case .fixedDays:
weekdayPicker
}
} header: {
Text("When")
}
}
private var reminderSection: some View {
Section {
Toggle("Remind Me", isOn: $reminderEnabled)
if reminderEnabled {
DatePicker("Time", selection: $reminderTime, displayedComponents: .hourAndMinute)
}
} header: {
Text("Reminder")
} footer: {
if reminderEnabled && notificationsDenied {
Text("Notifications are turned off for Workouts. Reminders won't arrive until you enable them in Settings.")
}
}
}
/// 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,
reminderMinutes: reminderEnabled ? Self.minutesFromMidnight(of: reminderTime) : 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
}
// MARK: - Reminder time conversion
/// A Date (today, local calendar) at the stored minutes-from-midnight, for the
/// hour-and-minute picker. Nil (no reminder yet) seeds a sensible 8:00 AM default.
private static func time(fromMinutes minutes: Int?) -> Date {
let startOfDay = Calendar.current.startOfDay(for: Date())
return Calendar.current.date(byAdding: .minute, value: minutes ?? 8 * 60, to: startOfDay)
?? startOfDay
}
/// The picked time back to minutes from midnight (local wall-clock).
private static func minutesFromMidnight(of time: Date) -> Int {
let comps = Calendar.current.dateComponents([.hour, .minute], from: time)
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}
}
// MARK: - Routine picker
/// The pushed routine-selection list: each routine with its own symbol and color
/// (the Today board's row treatment), a checkmark on the current selection.
/// Tapping selects and pops back.
private struct RoutinePickerList: View {
let routines: [Routine]
/// The resolved live id of the current selection (for the checkmark) — may differ
/// from the binding's stored value when a seed clone-on-edit redirect is in play.
let currentID: String?
@Binding var selectedID: String
@Environment(\.dismiss) private var dismiss
var body: some View {
List(routines) { routine in
Button {
selectedID = routine.id
dismiss()
} label: {
HStack(spacing: 12) {
Image(systemName: routine.systemImage)
.font(.title3)
.foregroundStyle(Color.color(from: routine.color))
.frame(width: 32)
Text(routine.name)
.foregroundStyle(.primary)
Spacer()
if routine.id == currentID {
Image(systemName: "checkmark")
.fontWeight(.semibold)
.foregroundStyle(Color.accentColor)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.navigationTitle("Routine")
.navigationBarTitleDisplayMode(.inline)
}
}