- 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)
263 lines
9.5 KiB
Swift
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
|
|
}
|
|
}
|