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.
This commit is contained in:
@@ -14,8 +14,13 @@ import SwiftData
|
||||
struct ExerciseListView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var split: Split
|
||||
// Resolve the split by id, not a captured entity: editing a seed's exercise from
|
||||
// here clones the seed (new identity, old entity deleted), which would dangle a
|
||||
// stored `Split`. `currentSplitID` follows that swap.
|
||||
@State private var splitID: String
|
||||
@Query private var splits: [Split]
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@@ -31,11 +36,41 @@ struct ExerciseListView: View {
|
||||
@State private var showingActivePrompt = false
|
||||
@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 }
|
||||
}
|
||||
|
||||
private var activeWorkouts: [Workout] {
|
||||
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let split {
|
||||
content(for: split)
|
||||
} else {
|
||||
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
|
||||
.task { dismiss() }
|
||||
}
|
||||
}
|
||||
// Navigate to the workout log once the entity appears in the cache.
|
||||
.navigationDestination(item: $resolvedWorkout) { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
}
|
||||
// Poll for the entity after we write the document.
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for split: Split) -> some View {
|
||||
Form {
|
||||
let sortedExercises = split.exercisesArray
|
||||
|
||||
@@ -83,15 +118,6 @@ struct ExerciseListView: View {
|
||||
.disabled(split.exercisesArray.isEmpty)
|
||||
}
|
||||
}
|
||||
// Navigate to the workout log once the entity appears in the cache.
|
||||
.navigationDestination(item: $resolvedWorkout) { workout in
|
||||
WorkoutLogListView(workout: workout)
|
||||
}
|
||||
// Poll for the entity after we write the document.
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
addExercises(names: exerciseNames)
|
||||
@@ -183,6 +209,7 @@ struct ExerciseListView: View {
|
||||
}
|
||||
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
guard let split else { return }
|
||||
var exercises = split.exercisesArray
|
||||
exercises.move(fromOffsets: source, toOffset: destination)
|
||||
var doc = SplitDocument(from: split)
|
||||
@@ -196,6 +223,7 @@ struct ExerciseListView: View {
|
||||
}
|
||||
|
||||
private func start() {
|
||||
guard let split else { return }
|
||||
let startDate = Date()
|
||||
let logs = split.exercisesArray.enumerated().map { i, ex in
|
||||
WorkoutLogDocument(
|
||||
@@ -226,6 +254,7 @@ struct ExerciseListView: View {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -246,6 +275,7 @@ struct ExerciseListView: View {
|
||||
}
|
||||
|
||||
private func deleteExercise(_ exercise: Exercise) {
|
||||
guard let split else { return }
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.exercises.removeAll { $0.id == exercise.id }
|
||||
for i in doc.exercises.indices {
|
||||
|
||||
@@ -16,7 +16,13 @@ struct SplitAddEditView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let split: Split?
|
||||
// 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. `splitID` is
|
||||
// nil in create mode.
|
||||
@State private var splitID: String?
|
||||
@Query private var splits: [Split]
|
||||
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
@State private var name: String = ""
|
||||
@@ -26,10 +32,10 @@ struct SplitAddEditView: View {
|
||||
@State private var showingIconPicker: Bool = false
|
||||
@State private var showingDeleteConfirmation: Bool = false
|
||||
|
||||
var isEditing: Bool { split != nil }
|
||||
var isEditing: Bool { splitID != nil }
|
||||
|
||||
init(split: Split?, onDelete: (() -> Void)? = nil) {
|
||||
self.split = split
|
||||
_splitID = State(initialValue: split?.id)
|
||||
self.onDelete = onDelete
|
||||
if let split = split {
|
||||
_name = State(initialValue: split.name)
|
||||
@@ -39,114 +45,134 @@ struct SplitAddEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var split: Split? {
|
||||
guard let splitID else { return nil }
|
||||
let id = sync.currentSplitID(for: splitID)
|
||||
return splits.first { $0.id == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("Name")) {
|
||||
TextField("Name", text: $name)
|
||||
.bold()
|
||||
}
|
||||
|
||||
Section(header: Text("Appearance")) {
|
||||
Picker("Color", selection: $color) {
|
||||
ForEach(availableColors, id: \.self) { colorName in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.color(from: colorName))
|
||||
.frame(width: 20, height: 20)
|
||||
Text(colorName.capitalized)
|
||||
}
|
||||
.tag(colorName)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showingIconPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Icon")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Activity Type", selection: $activityType) {
|
||||
ForEach(WorkoutActivityType.allCases, id: \.self) { type in
|
||||
Label(type.displayName, systemImage: type.systemImage).tag(type)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Activity Type")
|
||||
} footer: {
|
||||
Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.")
|
||||
}
|
||||
|
||||
if let split = split {
|
||||
Section(header: Text("Exercises")) {
|
||||
NavigationLink {
|
||||
ExerciseListView(split: split)
|
||||
} label: {
|
||||
ListItem(
|
||||
text: "Exercises",
|
||||
count: split.exercisesArray.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Delete Split", role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Split" : "New Split")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingIconPicker) {
|
||||
SFSymbolPicker(selection: $systemImage)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete This Split?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let split = split {
|
||||
Task {
|
||||
await sync.delete(split: split)
|
||||
}
|
||||
dismiss()
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete the split and all its exercises.")
|
||||
if isEditing && split == nil {
|
||||
// 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() }
|
||||
} else {
|
||||
form(for: split)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func form(for split: Split?) -> some View {
|
||||
Form {
|
||||
Section(header: Text("Name")) {
|
||||
TextField("Name", text: $name)
|
||||
.bold()
|
||||
}
|
||||
|
||||
Section(header: Text("Appearance")) {
|
||||
Picker("Color", selection: $color) {
|
||||
ForEach(availableColors, id: \.self) { colorName in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.color(from: colorName))
|
||||
.frame(width: 20, height: 20)
|
||||
Text(colorName.capitalized)
|
||||
}
|
||||
.tag(colorName)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showingIconPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Icon")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Activity Type", selection: $activityType) {
|
||||
ForEach(WorkoutActivityType.allCases, id: \.self) { type in
|
||||
Label(type.displayName, systemImage: type.systemImage).tag(type)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Activity Type")
|
||||
} footer: {
|
||||
Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.")
|
||||
}
|
||||
|
||||
if let split = split {
|
||||
Section(header: Text("Exercises")) {
|
||||
NavigationLink {
|
||||
ExerciseListView(split: split)
|
||||
} label: {
|
||||
ListItem(
|
||||
text: "Exercises",
|
||||
count: split.exercisesArray.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Delete Split", role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Split" : "New Split")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingIconPicker) {
|
||||
SFSymbolPicker(selection: $systemImage)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete This Split?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let split = split {
|
||||
Task {
|
||||
await sync.delete(split: split)
|
||||
}
|
||||
dismiss()
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete the split and all its exercises.")
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let split = split {
|
||||
// Update existing split
|
||||
if isEditing {
|
||||
// Update existing split. If the id no longer resolves (deleted remotely
|
||||
// mid-edit), there's nothing to save.
|
||||
guard let split = split else { return }
|
||||
var doc = SplitDocument(from: split)
|
||||
doc.name = name
|
||||
doc.color = color
|
||||
|
||||
@@ -16,7 +16,11 @@ struct SplitDetailView: View {
|
||||
@Environment(AppServices.self) private var services
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var split: Split
|
||||
// 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
|
||||
@@ -24,50 +28,87 @@ struct SplitDetailView: View {
|
||||
@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)
|
||||
}
|
||||
|
||||
Section(header: Text("Exercises")) {
|
||||
let sortedExercises = split.exercisesArray
|
||||
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: "\(item.sets) × \(item.reps) × \(weightUnit.format(item.weight))"
|
||||
)
|
||||
.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 {
|
||||
// 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)
|
||||
@@ -112,18 +153,29 @@ struct SplitDetailView: View {
|
||||
} 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)
|
||||
/// 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)
|
||||
doc.exercises = exercises.enumerated().map { i, ex in
|
||||
let ordered = groups.flatMap(\.exercises)
|
||||
doc.exercises = ordered.enumerated().map { i, ex in
|
||||
var ed = ExerciseDocument(from: ex)
|
||||
ed.order = i
|
||||
return ed
|
||||
@@ -133,6 +185,7 @@ struct SplitDetailView: View {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -156,6 +209,7 @@ struct SplitDetailView: View {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user