Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
This commit is contained in:
@@ -40,7 +40,7 @@ struct ExerciseProgressView: View {
|
||||
|
||||
/// True when this run was auto-advanced into from the previous exercise (flow mode).
|
||||
/// It suppresses the Ready page and begins the exercise immediately on appear — the
|
||||
/// whole point of a hands-free flowing split.
|
||||
/// whole point of a hands-free flowing routine.
|
||||
let enteredViaFlow: Bool
|
||||
|
||||
/// Invoked at the terminal rest's end in flow mode to hand off to the next exercise;
|
||||
@@ -196,11 +196,11 @@ struct ExerciseProgressView: View {
|
||||
/// appear, hands-free.
|
||||
private var showsReady: Bool { !enteredViaFlow }
|
||||
|
||||
/// This split runs as a continuous flow — finishing one exercise rests, then
|
||||
/// This routine runs as a continuous flow — finishing one exercise rests, then
|
||||
/// auto-advances into the next — rather than returning to the list per exercise.
|
||||
private var autoAdvance: Bool { doc.autoAdvance == true }
|
||||
|
||||
/// Rest length for this run: the split's per-split override, else the global default.
|
||||
/// Rest length for this run: the routine's per-routine override, else the global default.
|
||||
/// Governs both between-set rests and (in flow mode) the between-exercise rest.
|
||||
private var effectiveRest: Int { max(1, doc.restSeconds ?? restSeconds) }
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ struct ExerciseView: View {
|
||||
|
||||
/// Apply edited machine settings: update the log's plan-time snapshot (persisted
|
||||
/// with the workout), then write the same settings back to the originating
|
||||
/// split's exercise as the durable default — same sequencing as the workout
|
||||
/// routine's exercise as the durable default — same sequencing as the workout
|
||||
/// list's machine sheet.
|
||||
private func applyMachineSettings(_ settings: [MachineSetting]) {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
@@ -195,10 +195,10 @@ struct ExerciseView: View {
|
||||
doc.updatedAt = Date()
|
||||
let snapshot = doc
|
||||
let exerciseName = doc.logs[i].exerciseName
|
||||
let splitID = doc.splitID
|
||||
let routineID = doc.routineID
|
||||
Task {
|
||||
await sync.save(workout: snapshot)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,13 +163,13 @@ struct PlanEditView: View {
|
||||
let exerciseName = doc.logs[i].exerciseName
|
||||
let workoutDoc = doc
|
||||
|
||||
// 2) Mirror the plan onto the matching exercise in the split template,
|
||||
// 2) Mirror the plan onto the matching exercise in the routine template,
|
||||
// following the seed clone-on-edit redirect so a prior fork doesn't
|
||||
// silently break the mirror.
|
||||
var splitDoc: SplitDocument?
|
||||
if let splitID = doc.splitID,
|
||||
let split = CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext) {
|
||||
var sDoc = SplitDocument(from: split)
|
||||
var routineDoc: RoutineDocument?
|
||||
if let routineID = doc.routineID,
|
||||
let routine = CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID), in: modelContext) {
|
||||
var sDoc = RoutineDocument(from: routine)
|
||||
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
|
||||
sDoc.exercises[ei].sets = sets
|
||||
sDoc.exercises[ei].reps = reps
|
||||
@@ -177,14 +177,14 @@ struct PlanEditView: View {
|
||||
sDoc.exercises[ei].durationSeconds = totalSeconds
|
||||
sDoc.exercises[ei].loadType = selectedLoadType.rawValue
|
||||
sDoc.updatedAt = Date()
|
||||
splitDoc = sDoc
|
||||
routineDoc = sDoc
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
await sync.save(workout: workoutDoc)
|
||||
if let splitDoc {
|
||||
await sync.save(split: splitDoc)
|
||||
if let routineDoc {
|
||||
await sync.save(routine: routineDoc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Thin coordinator that lets a single pushed run walk across exercises in a flow-mode
|
||||
/// split (`autoAdvance`). It owns which log is on screen and swaps it when the running
|
||||
/// routine (`autoAdvance`). It owns which log is on screen and swaps it when the running
|
||||
/// `ExerciseProgressView` finishes an exercise; `.id(currentLogID)` rebuilds the child
|
||||
/// fresh for each exercise, so the delicate per-exercise run/mirror machinery stays
|
||||
/// exactly as it is — this only automates the "open the next exercise" step.
|
||||
///
|
||||
/// For a non-flow split nothing ever calls `advance`, so this is transparent: it presents
|
||||
/// For a non-flow routine nothing ever calls `advance`, so this is transparent: it presents
|
||||
/// one exercise and behaves identically to hosting `ExerciseProgressView` directly.
|
||||
struct RunFlowView: View {
|
||||
@Environment(LiveRunState.self) private var liveRun
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// WorkoutHistoryView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 6:52 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The full workout history list. Formerly the "Log" tab's root (`WorkoutLogsView`);
|
||||
/// now pushed from the Progress tab, which absorbed history in the four-tab redesign.
|
||||
struct WorkoutHistoryView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
|
||||
@State private var itemToDelete: Workout?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(workouts) { workout in
|
||||
NavigationLink {
|
||||
WorkoutLogListView(workout: workout)
|
||||
} label: {
|
||||
CalendarListItem(
|
||||
date: workout.start,
|
||||
title: workout.routineName ?? Routine.unnamed,
|
||||
subtitle: getSubtitle(for: workout),
|
||||
subtitle2: workout.statusName
|
||||
)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
itemToDelete = workout
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if workouts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Workouts Yet",
|
||||
systemImage: "list.bullet.clipboard",
|
||||
description: Text("Start a new workout from one of your routines.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("History")
|
||||
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
|
||||
// On the log screen so a stalled save is visible where editing happens.
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
SyncStatusBanner()
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Workout?",
|
||||
isPresented: Binding(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: itemToDelete
|
||||
) { workout in
|
||||
Button("Delete", role: .destructive) {
|
||||
Task { await sync.delete(workout: workout) }
|
||||
itemToDelete = nil
|
||||
}
|
||||
// Only offered when the workout actually has a Health record. Capture
|
||||
// the UUID before the delete prunes the cache entity.
|
||||
if let healthUUID = workout.metricHealthKitWorkoutUUID {
|
||||
Button("Delete + Remove from Apple Health", role: .destructive) {
|
||||
services.workoutHealthDeleter.deleteFromHealth(uuidString: healthUUID)
|
||||
Task { await sync.delete(workout: workout) }
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: { workout in
|
||||
if workout.metricSourceRaw == MetricSource.watch.rawValue {
|
||||
Text("This workout was recorded by Apple Watch; if it can't be removed from Apple Health here, delete it in the Health app.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getSubtitle(for workout: Workout) -> String {
|
||||
if workout.status == .completed, let endDate = workout.end {
|
||||
return workout.start.humanTimeInterval(to: endDate)
|
||||
} else {
|
||||
return workout.start.formattedDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,12 @@ struct WorkoutLogListView: View {
|
||||
doc.logs.sorted { $0.order < $1.order }
|
||||
}
|
||||
|
||||
/// The split this workout was started from (for adding more exercises),
|
||||
/// The routine this workout was started from (for adding more exercises),
|
||||
/// following the seed clone-on-edit redirect so a mid-workout fork still
|
||||
/// resolves to the live clone.
|
||||
private var split: Split? {
|
||||
guard let splitID = doc.splitID else { return nil }
|
||||
return CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext)
|
||||
private var routine: Routine? {
|
||||
guard let routineID = doc.routineID else { return nil }
|
||||
return CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID), in: modelContext)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -166,7 +166,7 @@ struct WorkoutLogListView: View {
|
||||
// doc so it shows the latest local state immediately.
|
||||
ExerciseView(workout: workout, logID: route.id, seedDoc: doc)
|
||||
}
|
||||
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||
.navigationTitle(doc.routineName ?? Routine.unnamed)
|
||||
// Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
|
||||
// cache reflects them, so the list shows live status on return.
|
||||
.onChange(of: workout.updatedAt) { _, _ in
|
||||
@@ -185,12 +185,12 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
SplitExercisePickerSheet(
|
||||
split: split,
|
||||
RoutineExercisePickerSheet(
|
||||
routine: routine,
|
||||
existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
|
||||
) { selection in
|
||||
switch selection {
|
||||
case .fromSplit(let exercise): addExerciseFromSplit(exercise)
|
||||
case .fromRoutine(let exercise): addExerciseFromRoutine(exercise)
|
||||
case .fromLibrary(let name): addExerciseFromLibrary(name)
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ struct WorkoutLogListView: View {
|
||||
/// (so the propped-phone mirror cover doesn't stack on top of it).
|
||||
@ViewBuilder
|
||||
private func progressView(logID: String) -> some View {
|
||||
// RunFlowView hosts the run and, in a flow-mode split, auto-advances across
|
||||
// RunFlowView hosts the run and, in a flow-mode routine, auto-advances across
|
||||
// exercises. It owns the live-run mirror wiring (incoming frame filter,
|
||||
// `navigatedRunID`, and the parameterized live-ended signal) since the on-screen
|
||||
// log advances during a flowing run.
|
||||
@@ -321,11 +321,11 @@ struct WorkoutLogListView: View {
|
||||
save()
|
||||
}
|
||||
|
||||
private func addExerciseFromSplit(_ exercise: Exercise) {
|
||||
private func addExerciseFromRoutine(_ exercise: Exercise) {
|
||||
appendExercise(ExerciseDocument(from: exercise))
|
||||
}
|
||||
|
||||
/// Add an exercise that isn't part of this workout's split, picked from the full
|
||||
/// Add an exercise that isn't part of this workout's routine, picked from the full
|
||||
/// exercise library. Seeds a plan from, in priority order: the most recent logged
|
||||
/// set of this exercise (any workout), the library's authored `**Defaults:**`, or
|
||||
/// a plain 3×10 fallback when neither exists.
|
||||
@@ -334,7 +334,7 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
|
||||
/// Append a freshly-planned exercise to the working doc, persist, and push
|
||||
/// straight into its progress flow. Shared tail of `addExerciseFromSplit` and
|
||||
/// straight into its progress flow. Shared tail of `addExerciseFromRoutine` and
|
||||
/// `addExerciseFromLibrary`.
|
||||
private func appendExercise(_ plan: ExerciseDocument) {
|
||||
let now = Date()
|
||||
@@ -354,7 +354,7 @@ struct WorkoutLogListView: View {
|
||||
addedLog = LogRoute(id: newLog.id)
|
||||
}
|
||||
|
||||
/// Plan defaults for a library exercise with no split entry: copy the most recent
|
||||
/// Plan defaults for a library exercise with no routine entry: copy the most recent
|
||||
/// logged set of this exercise (sets/reps/weight/loadType/duration and machine
|
||||
/// settings) if one exists anywhere; otherwise the library's authored defaults
|
||||
/// (weight always starts at 0 — there's no prior lift to copy); otherwise a plain
|
||||
@@ -396,9 +396,9 @@ struct WorkoutLogListView: View {
|
||||
|
||||
/// Apply edited machine settings from the sheet: update the log's plan-time
|
||||
/// snapshot (persisted with the workout), then write the same settings back to
|
||||
/// the originating split's exercise as the durable default. Sequenced in one
|
||||
/// task so the split's clone-on-edit repoint (which rewrites this workout's
|
||||
/// `splitID`) sees the log edit already persisted rather than clobbering it.
|
||||
/// the originating routine's exercise as the durable default. Sequenced in one
|
||||
/// task so the routine's clone-on-edit repoint (which rewrites this workout's
|
||||
/// `routineID`) sees the log edit already persisted rather than clobbering it.
|
||||
private func applyMachineSettings(_ settings: [MachineSetting], toLogID id: String) {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == id }) else { return }
|
||||
doc.logs[i].machineSettings = settings
|
||||
@@ -408,10 +408,10 @@ struct WorkoutLogListView: View {
|
||||
|
||||
let workoutSnapshot = doc
|
||||
let exerciseName = doc.logs[i].exerciseName
|
||||
let splitID = doc.splitID
|
||||
let routineID = doc.routineID
|
||||
Task {
|
||||
await sync.save(workout: workoutSnapshot)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,12 +579,12 @@ private struct WorkoutLogRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Split Exercise Picker Sheet
|
||||
// MARK: - Routine Exercise Picker Sheet
|
||||
|
||||
struct SplitExercisePickerSheet: View {
|
||||
struct RoutineExercisePickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let split: Split?
|
||||
let routine: Routine?
|
||||
let existingExerciseNames: Set<String>
|
||||
let onSelected: (Selection) -> Void
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
@@ -593,31 +593,31 @@ struct SplitExercisePickerSheet: View {
|
||||
/// library section's name list is stable for the sheet's lifetime.
|
||||
@State private var libraryInfoByName: [String: ExerciseInfo] = [:]
|
||||
|
||||
/// A pick from either source: the split path hands back the live `Exercise`
|
||||
/// A pick from either source: the routine path hands back the live `Exercise`
|
||||
/// entity (as today), the library path just the exercise name.
|
||||
enum Selection {
|
||||
case fromSplit(Exercise)
|
||||
case fromRoutine(Exercise)
|
||||
case fromLibrary(String)
|
||||
}
|
||||
|
||||
/// Section 1 — this workout's split, minus exercises already added.
|
||||
private var splitExercises: [Exercise] {
|
||||
guard let split else { return [] }
|
||||
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
|
||||
/// Section 1 — this workout's routine, minus exercises already added.
|
||||
private var routineExercises: [Exercise] {
|
||||
guard let routine else { return [] }
|
||||
return routine.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
|
||||
}
|
||||
|
||||
/// Section 2 — every library exercise not already in the workout and not
|
||||
/// already offered in section 1.
|
||||
private var libraryExerciseNames: [String] {
|
||||
let shown = Set(splitExercises.map(\.name))
|
||||
let shown = Set(routineExercises.map(\.name))
|
||||
return ExerciseMotionLibrary.exerciseNames.filter {
|
||||
!existingExerciseNames.contains($0) && !shown.contains($0)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredSplitExercises: [Exercise] {
|
||||
guard !searchText.isEmpty else { return splitExercises }
|
||||
return splitExercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
private var filteredRoutineExercises: [Exercise] {
|
||||
guard !searchText.isEmpty else { return routineExercises }
|
||||
return routineExercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
private var filteredLibraryNames: [String] {
|
||||
@@ -628,7 +628,7 @@ struct SplitExercisePickerSheet: View {
|
||||
/// Whether there's anything left to add at all — independent of the current
|
||||
/// search, so a no-results search doesn't get confused with a truly empty sheet.
|
||||
private var hasAnyExercises: Bool {
|
||||
!splitExercises.isEmpty || !libraryExerciseNames.isEmpty
|
||||
!routineExercises.isEmpty || !libraryExerciseNames.isEmpty
|
||||
}
|
||||
|
||||
/// "3 × 12" / "3 × 30 s" — the library's suggested plan for a row's caption.
|
||||
@@ -648,15 +648,15 @@ struct SplitExercisePickerSheet: View {
|
||||
systemImage: "checkmark.circle",
|
||||
description: Text("Every exercise is already part of this workout.")
|
||||
)
|
||||
} else if filteredSplitExercises.isEmpty && filteredLibraryNames.isEmpty {
|
||||
} else if filteredRoutineExercises.isEmpty && filteredLibraryNames.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
List {
|
||||
if !filteredSplitExercises.isEmpty, let split {
|
||||
Section("From \(split.name)") {
|
||||
ForEach(filteredSplitExercises) { exercise in
|
||||
if !filteredRoutineExercises.isEmpty, let routine {
|
||||
Section("From \(routine.name)") {
|
||||
ForEach(filteredRoutineExercises) { exercise in
|
||||
Button {
|
||||
onSelected(.fromSplit(exercise))
|
||||
onSelected(.fromRoutine(exercise))
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
@@ -730,7 +730,7 @@ struct SplitExercisePickerSheet: View {
|
||||
/// machine-based log (non-nil `machineSettings`), so there's no machine toggle —
|
||||
/// just the ordered rows (reusing `MachineSettingsEditor`) plus Save/Cancel. Save
|
||||
/// hands the edited array back to the caller, which persists it to the log and
|
||||
/// writes it back to the split's exercise as the new default. Shared with
|
||||
/// writes it back to the routine's exercise as the new default. Shared with
|
||||
/// `ExerciseView`'s Machine Settings section.
|
||||
struct MachineSettingsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -750,7 +750,7 @@ struct MachineSettingsSheet: View {
|
||||
Form {
|
||||
Section(
|
||||
header: Text("Settings"),
|
||||
footer: Text("Saved to this workout and set as the default for \(exerciseName) in its split.")
|
||||
footer: Text("Saved to this workout and set as the default for \(exerciseName) in its routine.")
|
||||
) {
|
||||
MachineSettingsEditor(settings: $settings)
|
||||
}
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
//
|
||||
// WorkoutLogsView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 6:52 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct WorkoutLogsView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
|
||||
@State private var showingSplitPicker = false
|
||||
@State private var showingSettings = false
|
||||
@State private var itemToDelete: Workout?
|
||||
/// ID of the just-started workout; drives the push into its log screen once the
|
||||
/// cache observer delivers the entity a beat after the file write (see
|
||||
/// `navigatesToStartedWorkout`).
|
||||
@State private var pendingWorkoutID: String?
|
||||
|
||||
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(workouts) { workout in
|
||||
NavigationLink {
|
||||
WorkoutLogListView(workout: workout)
|
||||
} label: {
|
||||
CalendarListItem(
|
||||
date: workout.start,
|
||||
title: workout.splitName ?? Split.unnamed,
|
||||
subtitle: getSubtitle(for: workout),
|
||||
subtitle2: workout.statusName
|
||||
)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button {
|
||||
itemToDelete = workout
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if workouts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Workouts Yet",
|
||||
systemImage: "list.bullet.clipboard",
|
||||
description: Text("Start a new workout from one of your splits.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Workout Logs")
|
||||
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
|
||||
// On the root screen so a stalled save is visible where editing happens.
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
SyncStatusBanner()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
showingSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape.2")
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Start New") {
|
||||
showingSplitPicker.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(isPresented: $showingSplitPicker) {
|
||||
SplitPickerSheet { pendingWorkoutID = $0 }
|
||||
}
|
||||
// Once "Start New" saves a workout, drop straight into its log screen —
|
||||
// the same landing the split's exercise-list start path uses.
|
||||
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
|
||||
.confirmationDialog(
|
||||
"Delete Workout?",
|
||||
isPresented: Binding(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: itemToDelete
|
||||
) { workout in
|
||||
Button("Delete", role: .destructive) {
|
||||
Task { await sync.delete(workout: workout) }
|
||||
itemToDelete = nil
|
||||
}
|
||||
// Only offered when the workout actually has a Health record. Capture
|
||||
// the UUID before the delete prunes the cache entity.
|
||||
if let healthUUID = workout.metricHealthKitWorkoutUUID {
|
||||
Button("Delete + Remove from Apple Health", role: .destructive) {
|
||||
services.workoutHealthDeleter.deleteFromHealth(uuidString: healthUUID)
|
||||
Task { await sync.delete(workout: workout) }
|
||||
itemToDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: { workout in
|
||||
if workout.metricSourceRaw == MetricSource.watch.rawValue {
|
||||
Text("This workout was recorded by Apple Watch; if it can't be removed from Apple Health here, delete it in the Health app.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getSubtitle(for workout: Workout) -> String {
|
||||
if workout.status == .completed, let endDate = workout.end {
|
||||
return workout.start.humanTimeInterval(to: endDate)
|
||||
} else {
|
||||
return workout.start.formattedDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Split Picker Sheet
|
||||
|
||||
struct SplitPickerSheet: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Called with the new workout's id right after it's saved, so the presenting
|
||||
/// screen can navigate into it once the cache catches up.
|
||||
var onStarted: (String) -> Void = { _ in }
|
||||
|
||||
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
|
||||
private var splits: [Split]
|
||||
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
|
||||
/// Set when the user picks a split while other workouts are still going — drives the
|
||||
/// "end the current one(s) or run in parallel?" prompt.
|
||||
@State private var splitAwaitingConfirmation: Split?
|
||||
|
||||
private var activeWorkouts: [Workout] {
|
||||
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||
}
|
||||
|
||||
/// The splits most recently trained, newest first, each with the start date
|
||||
/// of its latest workout — derived from the workout log (`workouts` is
|
||||
/// already sorted by start descending). Each workout's `splitID` follows the
|
||||
/// seed clone-on-edit redirect so a workout started from a since-forked seed
|
||||
/// still credits the live clone. Deduped, capped, and limited to splits that
|
||||
/// still exist.
|
||||
private var recentSplits: [(split: Split, lastStart: Date)] {
|
||||
var seen = Set<String>()
|
||||
var recents: [(split: Split, lastStart: Date)] = []
|
||||
for workout in workouts {
|
||||
guard let splitID = workout.splitID else { continue }
|
||||
let liveID = sync.currentSplitID(for: splitID)
|
||||
guard !seen.contains(liveID) else { continue }
|
||||
seen.insert(liveID)
|
||||
if let split = splits.first(where: { $0.id == liveID }) {
|
||||
recents.append((split, workout.start))
|
||||
if recents.count == 3 { break }
|
||||
}
|
||||
}
|
||||
return recents
|
||||
}
|
||||
|
||||
/// One selectable split row, shared by the Recent and All Splits sections.
|
||||
/// Recent rows pass `lastTrained` to show how long ago the split was run.
|
||||
private func splitRow(_ split: Split, lastTrained: Date? = nil) -> some View {
|
||||
Button {
|
||||
confirmAndStart(with: split)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: split.systemImage)
|
||||
.foregroundColor(Color.color(from: split.color))
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(split.name)
|
||||
if let lastTrained {
|
||||
Text(lastTrained.daysAgoLabel())
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if !recentSplits.isEmpty {
|
||||
Section("Recent") {
|
||||
ForEach(recentSplits, id: \.split.id) { recent in
|
||||
splitRow(recent.split, lastTrained: recent.lastStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(recentSplits.isEmpty ? "" : "All Splits") {
|
||||
ForEach(splits) { split in
|
||||
splitRow(split)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select a Split")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
activePromptTitle,
|
||||
isPresented: Binding(
|
||||
get: { splitAwaitingConfirmation != nil },
|
||||
set: { if !$0 { splitAwaitingConfirmation = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: splitAwaitingConfirmation
|
||||
) { split in
|
||||
Button("End Current & Start New") { endActiveThenStart(with: split) }
|
||||
Button("Start in Parallel") { start(with: split) }
|
||||
Button("Cancel", role: .cancel) { splitAwaitingConfirmation = nil }
|
||||
} message: { _ in
|
||||
Text(activePromptMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activePromptTitle: String {
|
||||
activeWorkouts.count == 1 ? "Workout in Progress" : "\(activeWorkouts.count) Workouts in Progress"
|
||||
}
|
||||
|
||||
private var activePromptMessage: String {
|
||||
let n = activeWorkouts.count
|
||||
let those = n == 1 ? "it" : "them"
|
||||
return "You already have \(n == 1 ? "a workout" : "\(n) workouts") going. End \(those) first, or run this one alongside."
|
||||
}
|
||||
|
||||
/// Prompt before starting if other workouts are still going; otherwise start straight away.
|
||||
private func confirmAndStart(with split: Split) {
|
||||
if activeWorkouts.isEmpty {
|
||||
start(with: split)
|
||||
} else {
|
||||
splitAwaitingConfirmation = split
|
||||
}
|
||||
}
|
||||
|
||||
/// End every in-flight workout (keeping its progress), then start the picked split.
|
||||
private func endActiveThenStart(with split: Split) {
|
||||
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
|
||||
splitAwaitingConfirmation = nil
|
||||
Task {
|
||||
for var doc in toEnd {
|
||||
doc.endKeepingProgress()
|
||||
await sync.save(workout: doc)
|
||||
}
|
||||
}
|
||||
start(with: split)
|
||||
}
|
||||
|
||||
private func start(with split: Split) {
|
||||
let startDate = Date()
|
||||
let logs = split.exercisesArray.enumerated().map { index, exercise in
|
||||
WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: index, date: startDate)
|
||||
}
|
||||
|
||||
// A freshly started workout has no `end` — only completion stamps it.
|
||||
let doc = WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
splitID: split.id,
|
||||
splitName: split.name,
|
||||
start: startDate,
|
||||
end: nil,
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
createdAt: startDate,
|
||||
updatedAt: startDate,
|
||||
logs: logs,
|
||||
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance
|
||||
)
|
||||
|
||||
// Hand the id back after the save so the presenter can poll the cache and
|
||||
// navigate into the run — mirroring the exercise-list start path.
|
||||
Task {
|
||||
await sync.save(workout: doc)
|
||||
onStarted(doc.id)
|
||||
}
|
||||
// Bring the Apple Watch up into the session so the user can run it from the wrist,
|
||||
// tagged with the split's activity type so the saved Health workout is categorized.
|
||||
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Started-Workout Navigation
|
||||
|
||||
/// Drives a programmatic push into a freshly-started workout's log screen. Both start
|
||||
/// paths — the split picker sheet and a split's exercise list — mint a `WorkoutDocument`,
|
||||
/// write it, then hand its id here; the workout row only materializes once the
|
||||
/// file→observer→cache loop round-trips, so we poll the cache for the entity and push it
|
||||
/// the moment it lands.
|
||||
private struct StartedWorkoutNavigator: ViewModifier {
|
||||
/// The pushed state is a plain value wrapping the run's id — never a retained
|
||||
/// `@Model`. This screen stays pushed for the whole workout, and the run's entity
|
||||
/// can be deleted and re-imported underneath it (observer remove/re-add churn, a
|
||||
/// remote delete); a `@Model` held in `@State` would be *invalidated* by then —
|
||||
/// deleted-and-saved models read `isDeleted == false` yet trap on any property
|
||||
/// access (the TestFlight 2.3 (125) "crashed when watch ended an exercise" report).
|
||||
private struct RunRoute: Identifiable, Hashable { let id: String }
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Binding var pendingWorkoutID: String?
|
||||
@State private var resolvedRun: RunRoute?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.navigationDestination(item: $resolvedRun) { route in
|
||||
// Re-fetch on every build so the destination always maps a live
|
||||
// entity; a re-imported run resolves to its fresh instance.
|
||||
if let workout = CacheMapper.fetchWorkout(id: route.id, in: modelContext) {
|
||||
WorkoutLogListView(workout: workout)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Workout Unavailable",
|
||||
systemImage: "xmark.circle",
|
||||
description: Text("This workout is no longer on this device."))
|
||||
}
|
||||
}
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll for the entity after we write the document (the file→observer→cache loop
|
||||
/// typically completes in well under a second). Clear the pending id once it
|
||||
/// resolves, or silently after ~3 s if it never arrives.
|
||||
private func pollForWorkout(id: String) {
|
||||
Task {
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
if CacheMapper.fetchWorkout(id: id, in: modelContext) != nil {
|
||||
resolvedRun = RunRoute(id: id)
|
||||
pendingWorkoutID = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
pendingWorkoutID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Navigate into a workout's log screen once it appears in the cache after being
|
||||
/// started. Bind to the state you set to the new workout's id right after saving it.
|
||||
func navigatesToStartedWorkout(pendingWorkoutID: Binding<String?>) -> some View {
|
||||
modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID))
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ struct WorkoutSummaryView: View {
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.top, 8)
|
||||
Text(workout.splitName ?? Split.unnamed)
|
||||
Text(workout.routineName ?? Routine.unnamed)
|
||||
.font(.title2.bold())
|
||||
|
||||
WorkoutMetricsView(workout: workout)
|
||||
|
||||
Reference in New Issue
Block a user