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.
202 lines
6.8 KiB
Swift
202 lines
6.8 KiB
Swift
//
|
|
// SplitAddEditView.swift
|
|
// Workouts
|
|
//
|
|
// Created by rzen on 7/18/25 at 9:42 AM.
|
|
//
|
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import IndieSync
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct SplitAddEditView: View {
|
|
@Environment(SyncEngine.self) private var sync
|
|
@Environment(\.modelContext) private var modelContext
|
|
@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. `splitID` is
|
|
// nil in create mode.
|
|
@State private var splitID: String?
|
|
@Query private var splits: [Split]
|
|
|
|
var onDelete: (() -> Void)?
|
|
|
|
@State private var name: String = ""
|
|
@State private var color: String = "indigo"
|
|
@State private var systemImage: String = "dumbbell.fill"
|
|
@State private var activityType: WorkoutActivityType = .traditionalStrength
|
|
@State private var showingIconPicker: Bool = false
|
|
@State private var showingDeleteConfirmation: Bool = false
|
|
|
|
var isEditing: Bool { splitID != nil }
|
|
|
|
init(split: Split?, onDelete: (() -> Void)? = nil) {
|
|
_splitID = State(initialValue: split?.id)
|
|
self.onDelete = onDelete
|
|
if let split = split {
|
|
_name = State(initialValue: split.name)
|
|
_color = State(initialValue: split.color)
|
|
_systemImage = State(initialValue: split.systemImage)
|
|
_activityType = State(initialValue: split.activityTypeEnum)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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 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
|
|
doc.systemImage = systemImage
|
|
doc.activityType = activityType.rawValue
|
|
doc.updatedAt = Date()
|
|
Task { await sync.save(split: doc) }
|
|
} else {
|
|
// Create new split
|
|
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
|
|
let doc = SplitDocument(
|
|
schemaVersion: SplitDocument.currentSchemaVersion,
|
|
id: ULID.make(),
|
|
name: name,
|
|
color: color,
|
|
systemImage: systemImage,
|
|
order: existing.count,
|
|
createdAt: Date(),
|
|
updatedAt: Date(),
|
|
exercises: [],
|
|
activityType: activityType.rawValue
|
|
)
|
|
Task { await sync.save(split: doc) }
|
|
}
|
|
}
|
|
}
|