The decode-failure freeze (BULLETPROOFING.md L3) is production-reachable after all - a phone app can update days before the watch app auto-updates, and every push in between fails to decode, silently freezing the watch at its last good sync. The bridge now tracks schemaMismatch (set on a failed decode, cleared on the next good apply) and ActiveWorkoutGateView shows an "Update both apps to resume sync" banner while it's set. Also give requestSync an error handler that logs the dropped pull (L2; deliberately no retry - the activation/reachability edges re-pull), and sync DEVICE-COMMUNICATION.md to the post-bulletproofing reality (T1 as optimization, session recovery, degraded pushes, surfaced mismatch). Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
181 lines
7.4 KiB
Swift
181 lines
7.4 KiB
Swift
//
|
|
// ActiveWorkoutGateView.swift
|
|
// Workouts Watch App
|
|
//
|
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// Root of the watch app. The watch only runs the workouts that are currently
|
|
/// active on the phone — there's no history browsing here. An "active" workout is a
|
|
/// cached one that isn't finished (`notStarted` or `inProgress`); a workout is
|
|
/// created on the phone as `notStarted` the moment a split is picked, and flips to
|
|
/// `completed` once every exercise is done, at which point it drops off this list.
|
|
///
|
|
/// The root shows every active workout (most-recent first); picking one opens its
|
|
/// exercise list.
|
|
struct ActiveWorkoutGateView: View {
|
|
@Environment(WatchConnectivityBridge.self) private var bridge
|
|
|
|
@Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout]
|
|
@Query private var splits: [Split]
|
|
|
|
/// Navigated workouts (depth 1). Held here — rather than relying on implicit
|
|
/// `NavigationLink` destinations — so we can pop back to the gate the moment the phone
|
|
/// takes over editing the run we're inside.
|
|
@State private var path: [ActiveWorkoutRoute] = []
|
|
|
|
private var activeWorkouts: [Workout] {
|
|
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
|
}
|
|
|
|
private func split(for workout: Workout) -> Split? {
|
|
guard let id = workout.splitID else { return nil }
|
|
return splits.first { $0.id == id }
|
|
}
|
|
|
|
/// True while the phone has this workout's exercise — or the split it came from — open
|
|
/// in an editor. The watch parks such a run and blocks re-entry so the phone owns the
|
|
/// edit and the watch can't forward a stale optimistic write over it.
|
|
private func isLockedForEditing(_ workout: Workout) -> Bool {
|
|
if let id = bridge.editingWorkoutID, id == workout.id { return true }
|
|
if let splitID = bridge.editingSplitID, splitID == workout.splitID { return true }
|
|
return false
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack(path: $path) {
|
|
Group {
|
|
if activeWorkouts.isEmpty {
|
|
emptyState
|
|
} else {
|
|
List {
|
|
ForEach(activeWorkouts) { workout in
|
|
row(for: workout)
|
|
}
|
|
}
|
|
.navigationTitle("In Progress")
|
|
}
|
|
}
|
|
.navigationDestination(for: ActiveWorkoutRoute.self) { route in
|
|
// Resolve from the full set (not just active) so a run that finishes while
|
|
// you're inside it still renders rather than blanking.
|
|
if let workout = workouts.first(where: { $0.id == route.workoutID }) {
|
|
WorkoutLogListView(workout: workout)
|
|
}
|
|
}
|
|
// The phone's pushes aren't decoding (its app updated first; this one hasn't
|
|
// yet) — everything shown is frozen at the last good sync. Say so rather than
|
|
// silently presenting stale workouts until the watch app catches up.
|
|
.safeAreaInset(edge: .bottom) {
|
|
if bridge.schemaMismatch {
|
|
Label("Update both apps to resume sync", systemImage: "exclamationmark.arrow.triangle.2.circlepath")
|
|
.font(.caption2)
|
|
.foregroundStyle(.orange)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 2)
|
|
.background(.ultraThinMaterial)
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
// Nothing to run yet — pull fresh state in case the phone just started one.
|
|
if activeWorkouts.isEmpty { bridge.requestSync() }
|
|
}
|
|
// The phone just entered (or left) an editor — if we're inside the now-locked run,
|
|
// pop back to the gate so re-entry rebuilds a fresh working copy. Also pop if the run
|
|
// we're inside was pruned (discarded/deleted on the phone, or aged out of the push).
|
|
.onChange(of: bridge.editingWorkoutID) { _, _ in popIfNavigatedRunUnavailable() }
|
|
.onChange(of: bridge.editingSplitID) { _, _ in popIfNavigatedRunUnavailable() }
|
|
.onChange(of: workouts.map(\.id)) { _, _ in popIfNavigatedRunUnavailable() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func row(for workout: Workout) -> some View {
|
|
if isLockedForEditing(workout) {
|
|
ActiveWorkoutRow(workout: workout, split: split(for: workout), editingOnPhone: true)
|
|
} else {
|
|
NavigationLink(value: ActiveWorkoutRoute(workoutID: workout.id)) {
|
|
ActiveWorkoutRow(workout: workout, split: split(for: workout))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// If the run we're currently navigated into is no longer available — pruned from the
|
|
/// cache (discarded/deleted on the phone, or aged out of the pushed set), or locked
|
|
/// because the phone took over editing it — pop back to the gate.
|
|
private func popIfNavigatedRunUnavailable() {
|
|
guard let route = path.last else { return }
|
|
guard let workout = workouts.first(where: { $0.id == route.workoutID }) else {
|
|
path.removeAll()
|
|
return
|
|
}
|
|
if isLockedForEditing(workout) { path.removeAll() }
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
ContentUnavailableView {
|
|
Label("Start a Workout", systemImage: "iphone")
|
|
} description: {
|
|
Text("Begin a workout on your iPhone to run it here.")
|
|
} actions: {
|
|
Button {
|
|
bridge.requestSync()
|
|
} label: {
|
|
Label("Refresh", systemImage: "arrow.triangle.2.circlepath")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Active Workout Row
|
|
|
|
private struct ActiveWorkoutRow: View {
|
|
let workout: Workout
|
|
let split: Split?
|
|
/// When true, the phone is editing this run — render it dimmed and non-tappable.
|
|
var editingOnPhone: Bool = false
|
|
|
|
private var logs: [WorkoutLog] { workout.logsArray }
|
|
private var doneCount: Int { logs.filter { $0.status == .completed }.count }
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: editingOnPhone ? "pencil" : (split?.systemImage ?? "figure.strengthtraining.traditional"))
|
|
.font(.title3)
|
|
.foregroundStyle(split.map { Color.color(from: $0.color) } ?? .workTint)
|
|
.frame(width: 26)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(workout.splitName ?? Split.unnamed)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
|
|
Text(subtitle)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.opacity(editingOnPhone ? 0.5 : 1)
|
|
}
|
|
|
|
private var subtitle: String {
|
|
if editingOnPhone { return "Editing on iPhone…" }
|
|
guard !logs.isEmpty else { return workout.start.formattedDate() }
|
|
if doneCount == 0 { return "Not started · \(logs.count) exercises" }
|
|
return "\(doneCount) of \(logs.count) done"
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation
|
|
|
|
/// Stable, hashable handle for a navigated run. Keyed by id (not the `Workout` object) so
|
|
/// the path survives cache rebuilds, and a distinct type so it never collides with the
|
|
/// log-id destination inside `WorkoutLogListView`.
|
|
private struct ActiveWorkoutRoute: Hashable {
|
|
let workoutID: String
|
|
}
|