TestFlight 2.3 (125) "crashed when watch ended an exercise": the
isDeleted guard from 85e1582 only covers the delete→save window. Once
the deletion is saved the model unregisters — isDeleted reads false
again, modelContext goes nil, and any persisted-property read still
traps (_InitialBackingData.getValue). StartedWorkoutNavigator retained
the run's @Model in @State for the whole workout, so an observer
remove/re-add churn (e.g. iCloud reachability flapping) invalidated it
underneath the pushed screen, and the watch's completion push triggered
the rebuild that read it.
Two layers: the fromLive/SplitDetailView guards now also require a
non-nil modelContext, and StartedWorkoutNavigator pushes a plain id
route, re-fetching the entity fresh on every destination build — a
re-imported run resolves to its live instance; a gone run shows a
placeholder instead of trapping.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
382 lines
15 KiB
Swift
382 lines
15 KiB
Swift
//
|
|
// 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))
|
|
}
|
|
}
|