Files
workouts/Workouts/Views/Splits/SplitDetailView.swift
T
rzen a93cbc30b7 Add library exercises mid-workout with plan defaults
Pick any exercise from the full library while a workout is running, not
just the ones in its split. The new exercise's plan is seeded from the
most recent log of that exercise, else the library's authored Defaults
line, else a plain 3x10. Adds a searchable two-section picker sheet and
a **Defaults:** bullet to all 47 library reference pages.

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

210 lines
8.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.
//
// SplitDetailView.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 SplitDetailView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(\.dismiss) private var dismiss
// Resolve the split 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 `Split`. `currentSplitID` follows that swap.
@State private var splitID: String
@Query private var splits: [Split]
@State private var showingExerciseAddSheet: Bool = false
@State private var showingSplitEditSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
@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 }
}
var body: some View {
Group {
if let split {
content(for: split)
} else {
// The id we held no longer maps to a live split (deleted on another
// device, or a transient mid-clone frame). Show nothing and leave.
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
.task { dismiss() }
}
}
// Editing this split (or any of its exercises, all reached from here) parks any
// active watch run sourced from it — matched by splitID — so the watch can't keep
// performing an exercise whose plan we're reconfiguring.
.onAppear { services.watchBridge.setEditingSplit(sync.currentSplitID(for: splitID)) }
.onDisappear { services.watchBridge.setEditingSplit(nil) }
}
@ViewBuilder
private func content(for split: Split) -> some View {
Form {
Section(header: Text("What is a Split?")) {
Text("A \"split\" is simply how you divide (or \"split 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 split.exercisesArray.isEmpty {
Section {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
} else {
Section {
ForEach(split.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(split.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingSplitEditSheet = true
} label: {
Image(systemName: "pencil")
}
}
}
.sheet(isPresented: $showingExerciseAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}
.sheet(isPresented: $showingSplitEditSheet) {
SplitAddEditView(split: split) {
dismiss()
}
}
.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?")
}
}
/// Reorder and renumber. Resolves the current split at call time so it
/// follows a clone-on-edit.
private func moveExercises(from source: IndexSet, to destination: Int) {
guard let split else { return }
var ordered = split.exercisesArray
ordered.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split)
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(split: doc) }
}
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) }
// 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 split else { return }
var doc = SplitDocument(from: split)
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(split: doc) }
}
}