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)
This commit is contained in:
@@ -9,8 +9,45 @@ import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Add / edit a schedule: pick a routine, tag it with a goal, and set how often it
|
||||
/// recurs. Fields are snapshotted in `init` so the sheet never holds the live entity.
|
||||
/// 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
|
||||
@@ -28,11 +65,19 @@ struct ScheduleAddEditView: View {
|
||||
/// 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 recurrence: ScheduleRecurrence
|
||||
@State private var when: WhenChoice
|
||||
@State private var weekdays: Set<Int>
|
||||
@State private var timesPerWeek: 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]`.
|
||||
@@ -40,17 +85,25 @@ struct ScheduleAddEditView: View {
|
||||
|
||||
/// `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()) {
|
||||
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)
|
||||
_recurrence = State(initialValue: schedule?.recurrenceEnum ?? .once)
|
||||
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
|
||||
_timesPerWeek = State(initialValue: schedule?.timesPerWeek ?? 3)
|
||||
_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 }
|
||||
@@ -60,7 +113,7 @@ struct ScheduleAddEditView: View {
|
||||
|
||||
private var canSave: Bool {
|
||||
guard selectedRoutine != nil else { return false }
|
||||
if recurrence == .fixedDays { return !weekdays.isEmpty }
|
||||
if when == .fixedDays { return !weekdays.isEmpty }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -68,17 +121,20 @@ struct ScheduleAddEditView: View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
routineSection
|
||||
goalSection
|
||||
recurrenceSection
|
||||
if when != .now {
|
||||
goalSection
|
||||
}
|
||||
whenSection
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule")
|
||||
.navigationTitle(isEditing ? "Edit Workout" : "New Workout")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save", action: save).disabled(!canSave)
|
||||
Button(when == .now ? "Start Workout" : "Save", action: save)
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -124,28 +180,26 @@ struct ScheduleAddEditView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recurrenceSection: some View {
|
||||
private var whenSection: some View {
|
||||
Section {
|
||||
Picker("Repeat", selection: $recurrence) {
|
||||
ForEach(ScheduleRecurrence.allCases, id: \.self) { r in
|
||||
Text(r.displayName).tag(r)
|
||||
Picker("When", selection: $when) {
|
||||
ForEach(WhenChoice.allCases) { choice in
|
||||
if choice != .now || offersNow {
|
||||
Text(choice.displayName).tag(choice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch recurrence {
|
||||
switch when {
|
||||
case .now, .daily:
|
||||
EmptyView()
|
||||
case .once:
|
||||
DatePicker("Date", selection: $date, displayedComponents: .date)
|
||||
case .daily:
|
||||
EmptyView()
|
||||
case .fixedDays:
|
||||
weekdayPicker
|
||||
case .frequency:
|
||||
Stepper(value: $timesPerWeek, in: 1...7) {
|
||||
Text("\(timesPerWeek)× per week")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Repeat")
|
||||
Text("When")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +228,15 @@ struct ScheduleAddEditView: View {
|
||||
|
||||
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(),
|
||||
@@ -182,7 +245,6 @@ struct ScheduleAddEditView: View {
|
||||
goal: goal?.rawValue,
|
||||
recurrence: recurrence.rawValue,
|
||||
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
|
||||
timesPerWeek: recurrence == .frequency ? timesPerWeek : nil,
|
||||
date: recurrence == .once ? date : nil,
|
||||
order: isEditing ? order : nextOrder(),
|
||||
createdAt: createdAt,
|
||||
|
||||
@@ -41,14 +41,35 @@ struct TodayView: View {
|
||||
/// The viewed day is strictly before today (day-precision).
|
||||
private var isViewingPastDay: Bool { selectedDate < Calendar.current.startOfDay(for: Date()) }
|
||||
|
||||
/// Schedules that belong on the viewed day's board: recurring ones always show
|
||||
/// (the board displays their recurrence, not a due-filter — yet); a one-off shows
|
||||
/// Schedules due on the viewed day: `.daily` always qualifies; `.fixedDays` only
|
||||
/// when the viewed day's calendar weekday is one of the schedule's days; `.once`
|
||||
/// only on its own day. A dateless `.once` (shouldn't happen) shows everywhere
|
||||
/// rather than nowhere.
|
||||
private var visibleSchedules: [Schedule] {
|
||||
schedules.filter { schedule in
|
||||
guard schedule.recurrenceEnum == .once, let date = schedule.date else { return true }
|
||||
return date.isSameDay(as: selectedDate)
|
||||
switch schedule.recurrenceEnum {
|
||||
case .daily:
|
||||
return true
|
||||
case .fixedDays:
|
||||
let weekday = Calendar.current.component(.weekday, from: selectedDate)
|
||||
return (schedule.weekdays ?? []).contains(weekday)
|
||||
case .once:
|
||||
guard let date = schedule.date else { return true }
|
||||
return date.isSameDay(as: selectedDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workouts on the viewed day whose routine isn't already shown via a schedule row
|
||||
/// above — e.g. an ad hoc "Start Now" workout, or a routine whose schedule isn't due
|
||||
/// today. Matched the same way `todaysWorkout` matches a workout to a schedule: both
|
||||
/// sides live-resolved through the seed clone-on-edit redirect.
|
||||
private var orphanWorkouts: [Workout] {
|
||||
let scheduledRoutineIDs = Set(visibleSchedules.map { sync.currentRoutineID(for: $0.routineID) })
|
||||
return workouts.filter { workout in
|
||||
guard workout.start.isSameDay(as: selectedDate) else { return false }
|
||||
guard let routineID = workout.routineID else { return true }
|
||||
return !scheduledRoutineIDs.contains(sync.currentRoutineID(for: routineID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,24 +121,25 @@ struct TodayView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !unassignedSchedules.isEmpty {
|
||||
if !unassignedSchedules.isEmpty || !orphanWorkouts.isEmpty {
|
||||
Section("Unassigned") {
|
||||
ForEach(unassignedSchedules) { scheduleRow($0) }
|
||||
ForEach(orphanWorkouts) { orphanWorkoutRow($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if schedules.isEmpty {
|
||||
if schedules.isEmpty && orphanWorkouts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Schedules Yet",
|
||||
systemImage: "calendar.badge.plus",
|
||||
description: Text("Plan your week by adding a schedule for a routine.")
|
||||
)
|
||||
} else if visibleSchedules.isEmpty {
|
||||
} else if visibleSchedules.isEmpty && orphanWorkouts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Nothing Planned",
|
||||
systemImage: "calendar",
|
||||
description: Text("Nothing is scheduled for this day. Tap + to plan something.")
|
||||
description: Text("Looks like a rest day. Tap + to plan or start a workout.")
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +172,7 @@ struct TodayView: View {
|
||||
Button {
|
||||
showingAddSchedule = true
|
||||
} label: {
|
||||
Label("Add Schedule", systemImage: "plus")
|
||||
Label("Add Workout", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,8 +189,9 @@ struct TodayView: View {
|
||||
}
|
||||
.sheet(isPresented: $showingAddSchedule) {
|
||||
// Seed a would-be one-off with the day on screen, so navigating to a
|
||||
// day and tapping + plans that day.
|
||||
ScheduleAddEditView(defaultDate: selectedDate)
|
||||
// day and tapping + plans that day. "Now" (offered only on today) hands
|
||||
// the routine back here to start immediately.
|
||||
ScheduleAddEditView(defaultDate: selectedDate, onStartNow: startNow)
|
||||
}
|
||||
.sheet(item: $scheduleToEdit) { schedule in
|
||||
ScheduleAddEditView(schedule: schedule)
|
||||
@@ -225,6 +248,38 @@ struct TodayView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A workout on the viewed day with no matching schedule row — an ad hoc "Start
|
||||
/// Now" run, or a routine whose schedule isn't due today. No recurrence caption
|
||||
/// (there's no schedule behind it), just the routine name and its status.
|
||||
private func orphanWorkoutRow(_ workout: Workout) -> some View {
|
||||
let routine = resolvedRoutine(id: workout.routineID)
|
||||
return Button {
|
||||
guard !workout.isDeleted else { return }
|
||||
pendingWorkoutID = workout.id
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
|
||||
.font(.title3)
|
||||
.foregroundStyle(routine.map { Color.color(from: $0.color) } ?? .secondary)
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(routine?.name ?? workout.routineName ?? Routine.unnamed)
|
||||
.foregroundStyle(.primary)
|
||||
Text("Ad Hoc")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
todayStatus(for: workout)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
|
||||
/// Today's status decoration: a completed run shows a green check + its finish
|
||||
/// time; an unfinished one shows an orange "In Progress" tag.
|
||||
@ViewBuilder
|
||||
@@ -272,7 +327,14 @@ struct TodayView: View {
|
||||
/// The live routine this schedule points at, following the seed clone-on-edit
|
||||
/// redirect; nil when the routine has been deleted.
|
||||
private func resolvedRoutine(for schedule: Schedule) -> Routine? {
|
||||
let liveID = sync.currentRoutineID(for: schedule.routineID)
|
||||
resolvedRoutine(id: schedule.routineID)
|
||||
}
|
||||
|
||||
/// The live routine for a routine id, following the seed clone-on-edit redirect;
|
||||
/// nil when there's no id or the routine has been deleted.
|
||||
private func resolvedRoutine(id: String?) -> Routine? {
|
||||
guard let id else { return nil }
|
||||
let liveID = sync.currentRoutineID(for: id)
|
||||
return routines.first { $0.id == liveID }
|
||||
}
|
||||
|
||||
@@ -325,4 +387,12 @@ struct TodayView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Now" path from the add sheet: start the chosen routine immediately and
|
||||
/// navigate in, same as tapping a due schedule row on today's board.
|
||||
private func startNow(routine: Routine) {
|
||||
Task {
|
||||
pendingWorkoutID = await WorkoutStarter.start(routine: routine, sync: sync)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user