Files
workouts/Workouts/Views/Exercises/ExerciseListView.swift
T
rzen e04eb83e70 Open new workouts directly and improve accessibility
Starting a workout — from the split picker or a split's exercise list —
now drops straight into its log screen once the cache catches up, via a
shared StartedWorkoutNavigator. Adds VoiceOver labels/values to the log
checkboxes and the settings button, a color-independent numbered legend
and spoken summary to the heart-rate-zone bar, and Dynamic Type scaling
to the run-flow badges and timer.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
2026-07-08 07:59:30 -04:00

261 lines
9.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ExerciseListView.swift
// Workouts
//
// Created by rzen on 7/18/25 at 8:38 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
struct ExerciseListView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
// Resolve the split by id, not a captured entity: editing a seed's exercise from
// here clones the seed (new identity, old entity deleted), which would dangle a
// stored `Split`. `currentSplitID` follows that swap.
@State private var splitID: String
@Query private var splits: [Split]
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
/// ID of the just-created workout; drives programmatic navigation once the
/// cache observer delivers the entity a beat after the file write (see
/// `navigatesToStartedWorkout`).
@State private var pendingWorkoutID: String? = nil
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@State private var showingActivePrompt = false
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(split: Split) {
_splitID = State(initialValue: split.id)
}
private var split: Split? {
let id = sync.currentSplitID(for: splitID)
return splits.first { $0.id == id }
}
private var activeWorkouts: [Workout] {
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
}
var body: some View {
Group {
if let split {
content(for: split)
} else {
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
.task { dismiss() }
}
}
// Navigate into the workout's log screen once the entity appears in the cache.
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
}
@ViewBuilder
private func content(for split: Split) -> some View {
Form {
let sortedExercises = split.exercisesArray
if !sortedExercises.isEmpty {
ForEach(sortedExercises) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(weightUnit.format(item.weight))"
)
.swipeActions {
Button {
itemToDelete = item
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
itemToEdit = item
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.indigo)
}
}
.onMove(perform: moveExercises)
Button {
showingAddSheet = true
} label: {
ListItem(title: "Add Exercise")
}
} else {
Text("No exercises added yet.")
Button(action: { showingAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
}
.navigationTitle(split.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Start This Split") {
confirmAndStart()
}
.disabled(split.exercisesArray.isEmpty)
}
}
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item, split: split)
}
.confirmationDialog(
"Delete Exercise?",
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible,
presenting: itemToDelete
) { item in
Button("Delete", role: .destructive) {
deleteExercise(item)
itemToDelete = nil
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
}
.confirmationDialog(
activePromptTitle,
isPresented: $showingActivePrompt,
titleVisibility: .visible
) {
Button("End Current & Start New") { endActiveThenStart() }
Button("Start in Parallel") { start() }
Button("Cancel", role: .cancel) { showingActivePrompt = false }
} message: {
Text(activePromptMessage)
}
}
// MARK: - Helpers
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() {
if activeWorkouts.isEmpty {
start()
} else {
showingActivePrompt = true
}
}
/// End every in-flight workout (keeping its progress), then start this split.
private func endActiveThenStart() {
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
showingActivePrompt = false
Task {
for var doc in toEnd {
doc.endKeepingProgress()
await sync.save(workout: doc)
}
}
start()
}
private func moveExercises(from source: IndexSet, to destination: Int) {
guard let split else { return }
var exercises = split.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split)
doc.exercises = exercises.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
private func start() {
guard let split else { return }
let startDate = Date()
let logs = split.exercisesArray.enumerated().map { i, ex in
WorkoutLogDocument(planFrom: ExerciseDocument(from: ex), order: i, date: startDate)
}
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
)
Task {
await sync.save(workout: doc)
pendingWorkoutID = doc.id
}
}
private func addExercises(names: [String]) {
guard let split else { return }
var doc = SplitDocument(from: split)
let existingNames = Set(doc.exercises.map { $0.name })
let base = doc.exercises.count
let newDocs = names
.filter { !existingNames.contains($0) }
.enumerated()
.map { i, exName -> ExerciseDocument in
// Seed from the library's authored `**Defaults:**` when available,
// falling back to a plain 3×10 weighted guess; weight always starts
// at 0 (there's no prior lift to seed it from).
let defaults = ExerciseInfoLibrary.info(for: exName)?.defaults
return ExerciseDocument(
id: ULID.make(), name: exName, order: base + i,
sets: defaults?.sets ?? 3, reps: defaults?.reps ?? 10, weight: 0,
loadType: (defaults?.loadType ?? .weight).rawValue,
durationSeconds: defaults?.durationSeconds ?? 0
)
}
doc.exercises.append(contentsOf: newDocs)
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
private func deleteExercise(_ exercise: Exercise) {
guard let split else { return }
var doc = SplitDocument(from: split)
doc.exercises.removeAll { $0.id == exercise.id }
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
}
}