Routine detail gains a read-time Usage section (last trained, completed workout count, linked schedules) resolved through the clone redirect, and both it and the edit sheet now explain that editing a starter saves your own copy. The add-exercise picker adopts the same curated category sections and name/category/muscle search as the exercise library. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
295 lines
12 KiB
Swift
295 lines
12 KiB
Swift
//
|
||
// RoutineDetailView.swift
|
||
// Workouts
|
||
//
|
||
// Created by rzen on 7/25/25 at 3:27 PM.
|
||
//
|
||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import IndieSync
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct RoutineDetailView: View {
|
||
@Environment(SyncEngine.self) private var sync
|
||
@Environment(AppServices.self) private var services
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
// Resolve the routine by id, not a captured entity: a clone-on-edit swaps a seed's
|
||
// identity mid-screen (the seed entity is deleted and a clone inserted), which
|
||
// would dangle a stored `Routine`. `currentRoutineID` follows that swap.
|
||
@State private var routineID: String
|
||
@Query private var routines: [Routine]
|
||
|
||
// Read-time usage stats (below) — completed workouts and linked schedules, both
|
||
// resolved through the seed clone-on-edit redirect so a since-edited starter still
|
||
// credits correctly.
|
||
@Query(sort: \Workout.start, order: .reverse)
|
||
private var workouts: [Workout]
|
||
@Query private var schedules: [Schedule]
|
||
|
||
@State private var showingExerciseAddSheet: Bool = false
|
||
@State private var showingRoutineEditSheet: Bool = false
|
||
@State private var itemToEdit: Exercise? = nil
|
||
@State private var itemToDelete: Exercise? = nil
|
||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||
|
||
init(routine: Routine) {
|
||
// A closure-based `NavigationLink` builds this destination eagerly for every
|
||
// row in the parent list, including during the update that fires when a routine
|
||
// is deleted — and reading any persisted property (even `id`) on a deleted
|
||
// `@Model` traps. `isDeleted` alone misses a deletion that has already been
|
||
// saved (the model unregisters: `isDeleted` false again, `modelContext` nil,
|
||
// reads still trap), so check both. An empty id maps to no live routine, so
|
||
// `body` shows the "Routine Unavailable" state and dismisses; the row is on its
|
||
// way out anyway.
|
||
let live = !routine.isDeleted && routine.modelContext != nil
|
||
_routineID = State(initialValue: live ? 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 {
|
||
// The id we held no longer maps to a live routine (deleted on another
|
||
// device, or a transient mid-clone frame). Show nothing and leave.
|
||
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
|
||
.task { dismiss() }
|
||
}
|
||
}
|
||
// Editing this routine (or any of its exercises, all reached from here) parks any
|
||
// active watch run sourced from it — matched by routineID — so the watch can't keep
|
||
// performing an exercise whose plan we're reconfiguring.
|
||
.onAppear { services.watchBridge.setEditingRoutine(sync.currentRoutineID(for: routineID)) }
|
||
.onDisappear { services.watchBridge.setEditingRoutine(nil) }
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func content(for routine: Routine) -> some View {
|
||
// Computed once per render rather than per-reference — both feed the Usage
|
||
// section's gate and its rows below.
|
||
let completed = completedWorkouts(for: routine)
|
||
let linked = linkedSchedules(for: routine)
|
||
Form {
|
||
Section {
|
||
Text("A \"routine\" is simply how you divide up your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||
.font(.caption)
|
||
} header: {
|
||
Text("What is a Routine?")
|
||
} footer: {
|
||
if SeedLibrary.isSeed(id: routine.id) {
|
||
Text("This is a starter routine. Your edits are saved as your own copy — the original stays available in the starter gallery.")
|
||
}
|
||
}
|
||
|
||
// Headerless — what the exercise list is needs no label; the section
|
||
// itself keeps the visual separation.
|
||
if routine.exercisesArray.isEmpty {
|
||
Section {
|
||
Text("No exercises added yet.")
|
||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||
ListItem(title: "Add Exercise")
|
||
}
|
||
}
|
||
} else {
|
||
Section {
|
||
ForEach(routine.exercisesArray) { item in
|
||
ListItem(
|
||
title: item.name,
|
||
subtitle: item.planSummary(weightUnit: weightUnit)
|
||
)
|
||
.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 { source, destination in
|
||
moveExercises(from: source, to: destination)
|
||
}
|
||
|
||
Button {
|
||
showingExerciseAddSheet = true
|
||
} label: {
|
||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||
}
|
||
}
|
||
}
|
||
|
||
if !completed.isEmpty || !linked.isEmpty {
|
||
Section("Usage") {
|
||
if let last = completed.first {
|
||
LabeledContent("Last Trained", value: last.start.daysAgoLabel())
|
||
}
|
||
LabeledContent("Workouts Completed", value: "\(completed.count)")
|
||
ForEach(linked) { schedule in
|
||
Label(schedule.recurrenceSummary, systemImage: "calendar")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(routine.name)
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button {
|
||
showingRoutineEditSheet = true
|
||
} label: {
|
||
Image(systemName: "pencil")
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $showingExerciseAddSheet) {
|
||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||
addExercises(names: exerciseNames)
|
||
}, allowMultiSelect: true,
|
||
inRoutineCounts: Dictionary(
|
||
routine.exercisesArray.map { ($0.libraryExerciseName, 1) },
|
||
uniquingKeysWith: +
|
||
))
|
||
}
|
||
.sheet(isPresented: $showingRoutineEditSheet) {
|
||
RoutineAddEditView(routine: routine) {
|
||
dismiss()
|
||
}
|
||
}
|
||
.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?")
|
||
}
|
||
}
|
||
|
||
/// Reorder and renumber. Resolves the current routine at call time so it
|
||
/// follows a clone-on-edit.
|
||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||
guard let routine else { return }
|
||
var ordered = routine.exercisesArray
|
||
ordered.move(fromOffsets: source, toOffset: destination)
|
||
|
||
var doc = RoutineDocument(from: routine)
|
||
doc.exercises = ordered.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) }
|
||
|
||
// If a single exercise was added, open the edit sheet once the cache refreshes.
|
||
// We rely on the observer to populate it — no direct entity reference needed.
|
||
}
|
||
|
||
private func deleteExercise(_ exercise: Exercise) {
|
||
guard let routine else { return }
|
||
var doc = RoutineDocument(from: routine)
|
||
doc.exercises.removeAll { $0.id == exercise.id }
|
||
// Re-number orders after removal
|
||
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) }
|
||
}
|
||
|
||
// MARK: - Usage
|
||
|
||
/// Completed workouts started from this routine, most recent first — `workouts`
|
||
/// is start-descending, so `.first` is the latest. Matched through the seed
|
||
/// clone-on-edit redirect on both sides, mirroring `RoutinesLibraryView`'s
|
||
/// `lastTrainedByRoutineID`.
|
||
private func completedWorkouts(for routine: Routine) -> [Workout] {
|
||
workouts.filter { workout in
|
||
guard workout.status == .completed, let routineID = workout.routineID else { return false }
|
||
return sync.currentRoutineID(for: routineID) == routine.id
|
||
}
|
||
}
|
||
|
||
/// Schedules pointing at this routine, resolved through the same redirect.
|
||
private func linkedSchedules(for routine: Routine) -> [Schedule] {
|
||
schedules.filter { sync.currentRoutineID(for: $0.routineID) == routine.id }
|
||
}
|
||
}
|