Files
workouts/Workouts/Views/Routines/RoutineDetailView.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
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
2026-07-11 07:53:01 -04:00

219 lines
8.8 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.
//
// 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]
@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 {
Form {
Section(header: Text("What is a Routine?")) {
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)
}
// 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 {
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")
}
}
}
}
.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)
}
.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 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(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) }
}
}