8ef0e96b31
Publish an exclusive-edit lock (editingWorkoutID / editingSplitID) in the phone→watch application context. While the phone has a workout's exercise (ExerciseView) or a split (SplitDetailView) open in an editor, the watch pops out of that run, blocks re-entry, and shows it as "Editing on iPhone" — so the two devices never drive the same run at once and the watch can't clobber the phone's edit with a stale optimistic write. The lock clears when the editor closes; absent keys in the latest-wins context mean "not editing". Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
167 lines
6.1 KiB
Swift
167 lines
6.1 KiB
Swift
//
|
||
// SplitDetailView.swift
|
||
// Workouts
|
||
//
|
||
// Created by rzen on 7/25/25 at 3:27 PM.
|
||
//
|
||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct SplitDetailView: View {
|
||
@Environment(SyncEngine.self) private var sync
|
||
@Environment(AppServices.self) private var services
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
var split: 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
|
||
|
||
var body: 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)
|
||
}
|
||
|
||
Section(header: Text("Exercises")) {
|
||
let sortedExercises = split.exercisesArray
|
||
|
||
if !sortedExercises.isEmpty {
|
||
ForEach(sortedExercises) { item in
|
||
ListItem(
|
||
title: item.name,
|
||
subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs"
|
||
)
|
||
.swipeActions {
|
||
Button {
|
||
itemToDelete = item
|
||
} label: {
|
||
Label("Delete", systemImage: "trash")
|
||
}
|
||
.tint(.red)
|
||
Button {
|
||
itemToEdit = item
|
||
} label: {
|
||
Label("Edit", systemImage: "pencil")
|
||
}
|
||
.tint(.indigo)
|
||
}
|
||
}
|
||
.onMove(perform: moveExercises)
|
||
|
||
Button {
|
||
showingExerciseAddSheet = true
|
||
} label: {
|
||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||
}
|
||
} else {
|
||
Text("No exercises added yet.")
|
||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||
ListItem(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?")
|
||
}
|
||
// 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(split.id) }
|
||
.onDisappear { services.watchBridge.setEditingSplit(nil) }
|
||
}
|
||
|
||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||
var exercises = split.exercisesArray
|
||
exercises.move(fromOffsets: source, toOffset: destination)
|
||
var doc = SplitDocument(from: split)
|
||
doc.exercises = exercises.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]) {
|
||
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) {
|
||
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) }
|
||
}
|
||
}
|