Machine-based exercises now carry ordered name/value comfort settings (seat height, back-rest position, ...) on both ExerciseDocument and, as a plan-time snapshot, WorkoutLogDocument — the optional array doubles as the machine flag. Editable from the exercise editor's new Machine section and from the workout row's settings sheet, whose mid-workout edits write back to the originating split (workout saved first, so seed clone-on-edit repointing can't clobber the log edit). Schema tightening rides the same rev: splits bump to v2 (weight-reminder fields and the unused exercise category removed), workouts to v3 (the derived `completed` flag removed; status is the single source). Starter seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema bumped to rebuild. SCHEMA.md documents the shapes. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
206 lines
7.6 KiB
Swift
206 lines
7.6 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)
|
|
}
|
|
|
|
// 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 in
|
|
ExerciseDocument(
|
|
id: ULID.make(), name: exName, order: base + i,
|
|
sets: 3, reps: 10, weight: 40,
|
|
loadType: LoadType.weight.rawValue,
|
|
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) }
|
|
}
|
|
}
|