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
338 lines
12 KiB
Swift
338 lines
12 KiB
Swift
//
|
||
// WorkoutLogListView.swift
|
||
// Workouts Watch App
|
||
//
|
||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import IndieSync
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct WorkoutLogListView: View {
|
||
@Environment(WatchConnectivityBridge.self) private var bridge
|
||
@Environment(WorkoutSessionManager.self) private var sessionManager
|
||
@Environment(\.modelContext) private var modelContext
|
||
|
||
/// Working copy of the workout. We drive the UI from this and mutate it on
|
||
/// every edit (then forward through the bridge) to avoid the read-after-write
|
||
/// race against the cache, which lags local writes by a beat.
|
||
@State private var doc: WorkoutDocument
|
||
|
||
/// The live cache entity, kept only to observe `updatedAt` for the absorb
|
||
/// below — all rendering and editing goes through the `doc` snapshot.
|
||
private let workout: Workout
|
||
|
||
@State private var showingExercisePicker = false
|
||
@State private var selectedLogID: String?
|
||
|
||
init(workout: Workout) {
|
||
self.workout = workout
|
||
// A closure-based `NavigationLink` builds this destination eagerly for every
|
||
// row in the parent list — including during the update that fires when a phone
|
||
// push removes a workout — so map only a live entity; a deleted one traps in
|
||
// SwiftData. The placeholder is never shown (the row is on its way out).
|
||
_doc = State(initialValue: WorkoutDocument(fromLive: workout) ?? .deletedPlaceholder)
|
||
}
|
||
|
||
/// The routine this workout came from (read-only on the watch), used to offer
|
||
/// additional exercises that aren't logged yet. Fetched imperatively — *not* via
|
||
/// `@Query` — so the list body never observes the live `Routine` or traverses its
|
||
/// `exercises` relationship during a render. Doing so (a `@Query`-observed model
|
||
/// whose to-many relationship is read in `body`) drove a SwiftData re-render loop
|
||
/// that hung the watch. `availableExercises` is therefore only ever evaluated from
|
||
/// the picker sheet's closure, not from `body`.
|
||
private var routine: Routine? {
|
||
guard let routineID = doc.routineID else { return nil }
|
||
return CacheMapper.fetchRoutine(id: routineID, in: modelContext)
|
||
}
|
||
|
||
private var sortedLogs: [WorkoutLogDocument] {
|
||
doc.logs.sorted { $0.order < $1.order }
|
||
}
|
||
|
||
private var availableExercises: [Exercise] {
|
||
guard let routine else { return [] }
|
||
let existingNames = Set(doc.logs.map { $0.exerciseName })
|
||
return routine.exercisesArray.filter { !existingNames.contains($0.name) }
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
if sessionManager.isRunning {
|
||
Section { LiveMetricsHUD() }
|
||
}
|
||
|
||
Section(header: Text(label)) {
|
||
ForEach(sortedLogs) { log in
|
||
Button {
|
||
selectedLogID = log.id
|
||
} label: {
|
||
WorkoutLogRowLabel(log: log)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
// Gate on the *resolved* routine, not the stale `routineID` string — a routine
|
||
// deleted on the phone leaves the id dangling, and offering Add Exercise
|
||
// against it would only show a misleading "All exercises added" picker.
|
||
if routine != nil {
|
||
Section {
|
||
Button {
|
||
showingExercisePicker = true
|
||
} label: {
|
||
HStack {
|
||
Image(systemName: "plus.circle.fill")
|
||
.foregroundColor(.green)
|
||
Text("Add Exercise")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.overlay {
|
||
if sortedLogs.isEmpty {
|
||
ContentUnavailableView(
|
||
"No Exercises",
|
||
systemImage: "figure.strengthtraining.traditional",
|
||
description: Text(routine == nil
|
||
? "No exercises in this workout."
|
||
: "Tap + to add exercises.")
|
||
)
|
||
}
|
||
}
|
||
.navigationTitle(doc.routineName ?? Routine.unnamed)
|
||
// Absorb phone edits while this screen is open (H1's watch-side gap):
|
||
// without this, the snapshot stays frozen at init, and the next watch
|
||
// tick — made against a state that's missing the phone's edit — would be
|
||
// stamped fresh and clobber it wholesale on the phone. Strictly-newer
|
||
// guard only: an equal timestamp is our own edit echoing back through
|
||
// the phone's push, and a stale push in flight (the phone pushed before
|
||
// ingesting our latest edit) must not regress what the user is looking
|
||
// at. Clock skew across devices degrades this to a skipped absorb —
|
||
// never to a clobber of local state.
|
||
.onChange(of: workout.updatedAt) { _, incoming in
|
||
// ...but never absorb from an entity a push has just deleted: mapping a
|
||
// deleted @Model traps.
|
||
if incoming > doc.updatedAt, let fresh = WorkoutDocument(fromLive: workout) {
|
||
doc = fresh
|
||
}
|
||
}
|
||
.navigationDestination(item: $selectedLogID) { logID in
|
||
// 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.
|
||
RunFlowView(
|
||
doc: $doc,
|
||
startLogID: logID,
|
||
onChange: { bridge.update(workout: doc) },
|
||
onLive: { bridge.sendLiveProgress($0) },
|
||
sendLiveEnded: { lid in bridge.sendLiveEnded(workoutID: doc.id, logID: lid) }
|
||
)
|
||
}
|
||
.sheet(isPresented: $showingExercisePicker) {
|
||
ExercisePickerView(exercises: availableExercises) { exercise in
|
||
addExercise(exercise)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var label: String {
|
||
let start = doc.start
|
||
if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end {
|
||
if start.isSameDay(as: end) {
|
||
return "\(start.formattedDate())—\(end.formattedTime())"
|
||
} else {
|
||
return "\(start.formattedDate())—\(end.formattedDate())"
|
||
}
|
||
}
|
||
return start.formattedDate()
|
||
}
|
||
|
||
private func addExercise(_ exercise: Exercise) {
|
||
var newLog = WorkoutLogDocument(
|
||
id: ULID.make(),
|
||
exerciseName: exercise.name,
|
||
order: doc.logs.count,
|
||
sets: exercise.sets,
|
||
reps: exercise.reps,
|
||
weight: exercise.weight,
|
||
loadType: exercise.loadType,
|
||
durationSeconds: exercise.durationTotalSeconds,
|
||
currentStateIndex: 0,
|
||
status: WorkoutStatus.notStarted.rawValue,
|
||
notes: nil,
|
||
date: doc.start
|
||
)
|
||
newLog.touch() // a fresh log carries a definite creation time for the merge
|
||
doc.logs.append(newLog)
|
||
doc.updatedAt = Date()
|
||
bridge.update(workout: doc)
|
||
showingExercisePicker = false
|
||
}
|
||
}
|
||
|
||
// MARK: - Live Metrics HUD
|
||
|
||
/// Compact live readout (heart rate + active calories) shown while a watch session is
|
||
/// running. Reads the session manager from the environment itself so its per-second
|
||
/// updates re-render only this row, not the editable log list above it. Renders
|
||
/// nothing until the first sample arrives, and dims under Always-On like the timers.
|
||
private struct LiveMetricsHUD: View {
|
||
@Environment(WorkoutSessionManager.self) private var sessionManager
|
||
@Environment(\.isLuminanceReduced) private var dimmed
|
||
|
||
var body: some View {
|
||
let heartRate = sessionManager.currentHeartRate
|
||
let energy = sessionManager.currentActiveEnergyKcal
|
||
if heartRate != nil || energy != nil {
|
||
HStack(spacing: 16) {
|
||
if let heartRate {
|
||
Label("\(Int(heartRate.rounded()))", systemImage: "heart.fill")
|
||
.foregroundStyle(dimmed ? Color.secondary : Color.red)
|
||
}
|
||
if let energy {
|
||
Label("\(Int(energy.rounded())) cal", systemImage: "flame.fill")
|
||
.foregroundStyle(dimmed ? Color.secondary : Color.orange)
|
||
}
|
||
}
|
||
.font(.caption.monospacedDigit())
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Workout Log Row Label
|
||
|
||
struct WorkoutLogRowLabel: View {
|
||
let log: WorkoutLogDocument
|
||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||
|
||
var body: some View {
|
||
HStack {
|
||
statusIcon
|
||
.foregroundColor(statusColor)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(log.exerciseName)
|
||
.font(.headline)
|
||
.lineLimit(1)
|
||
|
||
Text(subtitle)
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
private var status: WorkoutStatus {
|
||
WorkoutStatus(rawValue: log.status) ?? .notStarted
|
||
}
|
||
|
||
private var statusIcon: Image {
|
||
switch status {
|
||
case .completed:
|
||
Image(systemName: "checkmark.circle.fill")
|
||
case .inProgress:
|
||
Image(systemName: "circle.dotted")
|
||
case .notStarted:
|
||
Image(systemName: "circle")
|
||
case .skipped:
|
||
Image(systemName: "xmark.circle")
|
||
}
|
||
}
|
||
|
||
private var statusColor: Color {
|
||
switch status {
|
||
case .completed:
|
||
.accentColor
|
||
case .inProgress:
|
||
.gray
|
||
case .notStarted:
|
||
.secondary
|
||
case .skipped:
|
||
.secondary
|
||
}
|
||
}
|
||
|
||
private var subtitle: String {
|
||
if LoadType(rawValue: log.loadType) == .duration {
|
||
let mins = log.durationSeconds / 60
|
||
let secs = log.durationSeconds % 60
|
||
if mins > 0 && secs > 0 {
|
||
return "\(log.sets) × \(mins)m \(secs)s"
|
||
} else if mins > 0 {
|
||
return "\(log.sets) × \(mins) min"
|
||
} else {
|
||
return "\(log.sets) × \(secs) sec"
|
||
}
|
||
} else {
|
||
return "\(log.sets) × \(log.reps) × \(weightUnit.format(log.weight))"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Exercise Picker View
|
||
|
||
struct ExercisePickerView: View {
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
let exercises: [Exercise]
|
||
let onSelect: (Exercise) -> Void
|
||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
List {
|
||
if exercises.isEmpty {
|
||
Text("All exercises added")
|
||
.foregroundColor(.secondary)
|
||
} else {
|
||
ForEach(exercises) { exercise in
|
||
Button {
|
||
onSelect(exercise)
|
||
dismiss()
|
||
} label: {
|
||
VStack(alignment: .leading) {
|
||
Text(exercise.name)
|
||
.font(.headline)
|
||
Text(exerciseSubtitle(exercise))
|
||
.font(.caption2)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("Add Exercise")
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("Cancel") {
|
||
dismiss()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func exerciseSubtitle(_ exercise: Exercise) -> String {
|
||
if exercise.loadTypeEnum == .duration {
|
||
let mins = exercise.durationMinutes
|
||
let secs = exercise.durationSeconds
|
||
if mins > 0 && secs > 0 {
|
||
return "\(exercise.sets) × \(mins)m \(secs)s"
|
||
} else if mins > 0 {
|
||
return "\(exercise.sets) × \(mins) min"
|
||
} else {
|
||
return "\(exercise.sets) × \(secs) sec"
|
||
}
|
||
} else {
|
||
return "\(exercise.sets) × \(exercise.reps) × \(weightUnit.format(exercise.weight))"
|
||
}
|
||
}
|
||
}
|