Files
workouts/Workouts/Views/Splits/SplitDetailView.swift
T
rzen 936a585ece Seed starter splits deterministically with immutable clone-on-edit seeds
Starter splits ship as byte-canonical SplitDocument JSON with fixed
ULIDs (Workouts/Resources/StarterSplits, regenerated by
Scripts/generate_starter_splits.swift) and auto-seed after connect into
a verifiably empty container, re-checked after a settle delay — wrong
guesses are harmless because identical bytes make same-path conflicts
empty and tombstones reap resurrected seeds. Seeds are immutable:
SyncEngine.save(split:) forks an edited seed to a fresh ULID and
soft-deletes the original, whose stub is exempt from pruning
(IndieSync 0.3.0 prune(exempting:)) and vetoes resurrection forever;
split views resolve by id through a redirect map to follow the swap.
Add Starter Splits in Settings restores deleted seeds by lifting the
veto stub and rewriting the bundle bytes.

Also fixes ingestFromWatch bypassing the tombstone veto (a phone-deleted
workout resurrected when a stale watch resent it) and reaps a live file
immediately when its tombstone arrives.

SplitDetailView also picks up the category-grouped exercise sections
from the exercise-category work.
2026-07-06 01:16:05 -04:00

223 lines
8.9 KiB
Swift

//
// 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)
}
// One section per category (warm-up first). A split with no warm-ups keeps
// the single plain "Exercises" section it always had.
if split.exercisesArray.isEmpty {
Section(header: Text("Exercises")) {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
} else {
let grouped = groupedExercises(for: split)
ForEach(grouped, id: \.category) { group in
Section(header: Text(grouped.count == 1 ? "Exercises" : group.category.displayName)) {
ForEach(group.exercises) { 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(in: group.category, from: source, to: destination)
}
if group.category == grouped.last?.category {
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?")
}
}
/// Exercises bucketed by category in display order, dropping empty buckets.
private func groupedExercises(for split: Split) -> [(category: ExerciseCategory, exercises: [Exercise])] {
let all = split.exercisesArray
return ExerciseCategory.displayOrder.compactMap { category in
let members = all.filter { $0.categoryEnum == category }
return members.isEmpty ? nil : (category, members)
}
}
/// Reorder within one category's section, then renumber globally with the
/// sections' display order (warm-ups first) as the canonical file order. Resolves
/// the current split at call time so it follows a clone-on-edit.
private func moveExercises(in category: ExerciseCategory, from source: IndexSet, to destination: Int) {
guard let split else { return }
var groups = groupedExercises(for: split)
guard let gi = groups.firstIndex(where: { $0.category == category }) else { return }
groups[gi].exercises.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split)
let ordered = groups.flatMap(\.exercises)
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 in
ExerciseDocument(
id: ULID.make(), name: exName, order: base + i,
sets: 3, reps: 10, weight: 40,
loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
)
}
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) }
}
}