// // 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 @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 } } /// 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 } } .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 { NavigationLink { RoutinePickerList(routines: routines, 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") } } /// 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())) ?? [] return (existing.map(\.order).max() ?? -1) + 1 } } // 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] @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 == selectedID { Image(systemName: "checkmark") .fontWeight(.semibold) .foregroundStyle(Color.accentColor) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } .navigationTitle("Routine") .navigationBarTitleDisplayMode(.inline) } }