Files
workouts/Workouts/Views/Exercises/ExerciseListView.swift
T
rzen df5c77eee1 Let routines rename exercises and repeat them for interval segments
An exercise's name is now a per-routine display name: a new optional
libraryName on ExerciseDocument (snapshotted onto WorkoutLogDocument at
plan time) keeps the link to the bundled library exercise, and every
figure/info/cue lookup resolves libraryName ?? name. Deliberately not
schema-bumped — an older app dropping the key only strands the
figure link, same rationale as activityType. Cache schema bumped to 9
for the new columns.

The picker no longer filters out exercises already in the routine
(an "×N" badge marks them instead), exercise rows gain a leading
Duplicate swipe that clones an entry in place, and the edit sheet gets
a Name field with the library exercise shown read-only above it.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-12 18:38:14 -04:00

199 lines
7.1 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 routine 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 `Routine`. `currentRoutineID` follows that swap.
@State private var routineID: String
@Query private var routines: [Routine]
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(routine: Routine) {
_routineID = State(initialValue: routine.id)
}
private var routine: Routine? {
let id = sync.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
Group {
if let routine {
content(for: routine)
} else {
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
.task { dismiss() }
}
}
}
@ViewBuilder
private func content(for routine: Routine) -> some View {
Form {
let sortedExercises = routine.exercisesArray
if !sortedExercises.isEmpty {
ForEach(sortedExercises) { item in
ListItem(
title: item.name,
subtitle: "\(item.sets) × \(item.reps) × \(weightUnit.format(item.weight))"
)
.swipeActions(edge: .leading) {
Button {
duplicateExercise(item)
} label: {
Label("Duplicate", systemImage: "plus.square.on.square")
}
.tint(.teal)
}
.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(routine.name)
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true,
inRoutineCounts: Dictionary(
routine.exercisesArray.map { ($0.libraryExerciseName, 1) },
uniquingKeysWith: +
))
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item, routine: routine)
}
.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 routine?")
}
}
// MARK: - Helpers
private func moveExercises(from source: IndexSet, to destination: Int) {
guard let routine else { return }
var exercises = routine.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
var doc = RoutineDocument(from: routine)
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(routine: doc) }
}
private func addExercises(names: [String]) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
let base = doc.exercises.count
let newDocs = names
.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(routine: doc) }
}
private func deleteExercise(_ exercise: Exercise) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
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(routine: doc) }
}
/// Copy an exercise in place, right after itself — the interval-routine case
/// (a treadmill's "Warmup 5 min" / "Brisk Walk 10 min" / … segments all derive
/// from the same library exercise). The clone starts out an exact duplicate,
/// plan and any customized name included; renaming it is a follow-up edit.
private func duplicateExercise(_ exercise: Exercise) {
guard let routine else { return }
var doc = RoutineDocument(from: routine)
guard let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) else { return }
var copy = doc.exercises[idx]
copy.id = ULID.make()
doc.exercises.insert(copy, at: idx + 1)
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(routine: doc) }
}
}