diff --git a/CHANGELOG.md b/CHANGELOG.md index a9f06b1..a526a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ **July 2026** +Starter routines now appear automatically the first time you use the app, instead of waiting behind a button in Settings. + +Editing a starter split now turns it into your own copy, so the built-in version can always be brought back later. + +Add Starter Splits in Settings now restores any starters you've deleted or customized, without duplicating the ones you already have. + +Fixed a deleted workout reappearing when your Apple Watch hadn't yet heard about the deletion. + +The exercise screen now shows an animated figure demonstrating proper form, starting with the Bodyweight Core moves like Bird Dog and Plank. + +New Bodyweight Core starter split: a short warm-up plus a three-round circuit of planks, holds, and slow core moves — no equipment needed. + +Exercises in a split can now be marked as warm-up or main circuit, and the split screen groups its list accordingly. + +Opening an exercise you've already completed now shows a big green check instead of dropping you back into its timers. + +Timed exercises like planks now show their hold time in exercise lists instead of reps and weight. + When deleting a workout that was saved to Apple Health, you can now choose to remove it from Apple Health too. Workouts recorded by Apple Watch may need to be deleted in the Health app instead. Sync problems are now shown in Settings instead of failing silently. diff --git a/CLAUDE.md b/CLAUDE.md index c0c713d..62993c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ A workout tracking app for iPhone and Apple Watch, built with Swift 6 and SwiftU - **Key types**: - `SyncEngine` (`@Observable @MainActor`, in `Workouts/Sync/`) — the app-specific orchestrator. `connect()` resolves the ubiquity container and reconciles; `save`/`delete` write files; `handle(_:)` applies observer delta batches; `ingestFromWatch(_:)` writes + upserts the cache directly. Exposes `iCloudStatus` and an `onCacheChanged` callback. - The file-side core comes from the **IndieSync** SPM package (`https://git.rzen.dev/rzen/indie-sync.git`): `DocumentFileStore` (actor — all `NSFileCoordinator` I/O, conflict resolution, eviction-safe reads, placeholder-aware enumeration), `TombstoneStore` (soft-delete stubs, resurrection veto, grace-period pruning), `MetadataObserver` (`@MainActor` — wraps the `NSMetadataQuery`, emitting added/modified/removed batches as an `AsyncStream`), plus `ULID`, `DocumentCoder`, `SyncError`, and the `VersionedDocument` protocol. See the `icloud-sync-engine` skill for the architecture invariants. -- **Soft deletes**: a delete writes a `Tombstone` to `Stubs/.json` then removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period. +- **Soft deletes**: a delete writes a `Tombstone` to `Stubs/.json` then removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period — except starter-seed stubs, which are exempt (`prune(exempting:)`) and veto seed resurrection forever. - **iCloud is required**: `RootGateView` (`Workouts/Views/`) gates the app on `SyncEngine.iCloudStatus` — there is no local-only mode. ### Data Layer (three shapes + a stateless mapper) @@ -67,9 +67,9 @@ All in `Shared/Model/`: - `Workouts Watch App/` (watchOS): `Connectivity/`, `Views/`, plus `WorkoutSessionManager`, `WatchAppDelegate`, `WatchAppServices` - Root: `project.yml`, `Scripts/`, and `CHANGELOG.md` / `README.md` / `LICENSE.md` (bundled into the iOS app for IndieAbout) -### Starter Data (on-demand) -Seeded on demand, never automatically — an empty cache at launch is indistinguishable from an iCloud library that hasn't downloaded yet: -- **Starter splits**: `Workouts/Seed/SplitSeeder.swift` — a curated machine-based routine (Upper Body, Core, Lower Body) at 4×10 with default weights; idempotent by name. +### Starter Data (deterministic seeds) +- **Starter splits**: shipped as byte-canonical `SplitDocument` JSON in `Workouts/Resources/StarterSplits/*.split.json` with **fixed ULIDs** (shared `01DXF6DT00` prefix, frozen 2020 timestamp) and fixed content, regenerated only by `Scripts/generate_starter_splits.swift`. `SeedLibrary` (`Workouts/Seed/`) loads the catalog; **seeds are immutable** — `SyncEngine.save(split:)` transparently clones an edited seed to a fresh ULID and soft-deletes the seed, whose stub is exempt from pruning and vetoes resurrection forever (open views follow the identity swap via `sync.currentSplitID(for:)`). +- **Auto-seed**: `SyncEngine.autoSeedIfEmpty()` writes the verbatim bundle bytes after connect, only into a verifiably empty container (no data files, no stubs) re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and stubs reap resurrected seeds. The on-demand path (`SplitSeeder.seedDefaults`, "Add Starter Splits") restores deleted seeds (lifting the veto stub) or writes missing ones, skipping live names. - **Exercise catalogs**: `Workouts/Resources/*.exercises.yaml` (Planet Fitness + bodyweight), parsed with Yams, used by the exercise picker as a reference catalog. ## Guidelines diff --git a/README.md b/README.md index 8a07a9a..3ccf7d6 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,26 @@ your own iCloud Drive. ## Key Features - **Workout splits** — organize exercises into reusable routines with custom - colors, icons, and an activity type (strength, HIIT, cardio, …). Start with - built-in starter splits (Upper Body / Core / Lower Body) generated from a - bundled exercise catalog. + colors, icons, and an activity type (strength, HIIT, cardio, …). Each exercise + is tagged as **warm-up** or **main circuit**, and the split screen groups its + list accordingly. Built-in starter splits (Upper Body / Core / Lower Body + machine routines, plus an equipment-free **Bodyweight Core** circuit with its + own warm-up) appear automatically on first launch; editing one makes it your + own copy, and deleted starters can be restored from Settings. - **Exercise library** — a bundled catalog of starter exercises (bodyweight and - machine-based) to populate your splits. + machine-based) to populate your splits, plus a growing reference library + (`Exercise Library/`) with per-exercise setup, cues, mistakes, progressions, + and a rig-animated stick-figure visual system. - **Run a workout** — start a session from a split, then tap an exercise to run it as a paged flow: a **Ready?** lead-in, count-up work phases, count-down rests, and a **Finish** page — mirroring the Apple Watch. Swipe a row to mark it complete, or swipe to edit its plan (sets/reps/weight or duration) and notes. End a workout early from the **⋯** menu — **Save** keeps your progress (remaining exercises are marked skipped) or **Discard** removes it. +- **Animated form guide** — exercises with a bundled motion (the bodyweight core + moves, e.g. Bird Dog) show a looping stick-figure demonstration on the run + screen's bottom half, rendered live from the Exercise Library's rig data with + the working limbs highlighted. - **Progress tracking** — weight-progression charts per exercise across past sessions. - **Apple Watch companion** — starting a workout on the iPhone launches the watch diff --git a/Scripts/generate_starter_splits.swift b/Scripts/generate_starter_splits.swift new file mode 100644 index 0000000..d767069 --- /dev/null +++ b/Scripts/generate_starter_splits.swift @@ -0,0 +1,132 @@ +// Generates the bundled starter-split JSON resources for Workouts. +// Deterministic: fixed ULID timestamp (2020-01-01T00:00:00Z) + SHA256-of-label +// randomness, fixed document timestamps, encoder matching DocumentCoder +// (prettyPrinted + sortedKeys + iso8601). Re-running always emits identical bytes. +import Foundation +import CryptoKit + +// MARK: - ULID (Crockford base32, 48-bit ms timestamp + 80 deterministic bits) + +let crockford = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ") + +func deterministicULID(label: String) -> String { + let timestampMS: UInt64 = 1_577_836_800_000 // 2020-01-01T00:00:00Z + var bytes = [UInt8]() + for shift in stride(from: 40, through: 0, by: -8) { + bytes.append(UInt8((timestampMS >> UInt64(shift)) & 0xFF)) + } + let digest = SHA256.hash(data: Data(label.utf8)) + bytes.append(contentsOf: Array(digest).prefix(10)) + precondition(bytes.count == 16) + + // Encode 128 bits as 26 Crockford base32 chars: big-int division by 32, 26 digits. + var chars = [Character]() + var num = bytes + for _ in 0..<26 { + var remainder = 0 + var quotient = [UInt8]() + for byte in num { + let acc = remainder * 256 + Int(byte) + quotient.append(UInt8(acc / 32)) + remainder = acc % 32 + } + chars.append(crockford[remainder]) + num = quotient + } + return String(chars.reversed()) +} + +// MARK: - Document shapes (field names must mirror Shared/Model/Documents.swift) + +struct ExerciseDocument: Codable { + var id: String + var name: String + var order: Int + var sets: Int + var reps: Int + var weight: Int + var loadType: Int + var durationSeconds: Int + var weightLastUpdated: Date? + var weightReminderWeeks: Int + var category: Int? +} + +struct SplitDocument: Codable { + var schemaVersion: Int + var id: String + var name: String + var color: String + var systemImage: String + var order: Int + var createdAt: Date + var updatedAt: Date + var exercises: [ExerciseDocument] + var activityType: Int? +} + +// MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json) + +struct Ex { let name: String; var weight = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0; var cat = 0 } +struct Sp { let name: String; let color: String; let icon: String; let activity: Int; let ex: [Ex] } + +let seeds: [Sp] = [ + Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [ + Ex(name: "Lat Pull Down", weight: 110), + Ex(name: "Tricep Press", weight: 100), + Ex(name: "Chest Press", weight: 40), + Ex(name: "Seated Row", weight: 90), + ]), + Sp(name: "Core", color: "orange", icon: "figure.core.training", activity: 3, ex: [ + Ex(name: "Abdominal", weight: 0), + Ex(name: "Rotary", weight: 0), + ]), + Sp(name: "Lower Body", color: "green", icon: "figure.run", activity: 0, ex: [ + Ex(name: "Abductor", weight: 140), + Ex(name: "Adductor", weight: 140), + Ex(name: "Leg Press", weight: 160), + Ex(name: "Leg Curl", weight: 70), + ]), + Sp(name: "Bodyweight Core", color: "teal", icon: "figure.core.training", activity: 3, ex: [ + Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0, cat: 1), + Ex(name: "Dead Bug", sets: 1, reps: 8, load: 0, cat: 1), + Ex(name: "Bird Dog", sets: 1, reps: 8, load: 0, cat: 1), + Ex(name: "Plank", sets: 3, reps: 0, load: 2, dur: 45), + Ex(name: "Hollow Body Hold", sets: 3, reps: 0, load: 2, dur: 30), + Ex(name: "Side Plank", sets: 3, reps: 0, load: 2, dur: 30), + Ex(name: "Leg Raises", sets: 3, reps: 12, load: 0), + Ex(name: "Bird Dog", sets: 3, reps: 8, load: 0), + Ex(name: "Reverse Crunch", sets: 3, reps: 12, load: 0), + ]), +] + +// MARK: - Emit + +let encoder = JSONEncoder() +encoder.outputFormatting = [.prettyPrinted, .sortedKeys] +encoder.dateEncodingStrategy = .iso8601 + +let fixedDate = ISO8601DateFormatter().date(from: "2020-01-01T00:00:00Z")! +let outDir = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) +try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + +for (order, sp) in seeds.enumerated() { + let slug = sp.name.lowercased().replacingOccurrences(of: " ", with: "-") + let doc = SplitDocument( + schemaVersion: 1, + id: deterministicULID(label: "starter.\(slug)"), + name: sp.name, color: sp.color, systemImage: sp.icon, order: order, + createdAt: fixedDate, updatedAt: fixedDate, + exercises: sp.ex.enumerated().map { idx, e in + ExerciseDocument( + id: deterministicULID(label: "starter.\(slug).ex\(idx)"), + name: e.name, order: idx, sets: e.sets, reps: e.reps, weight: e.weight, + loadType: e.load, durationSeconds: e.dur, weightLastUpdated: nil, + weightReminderWeeks: 2, category: e.cat) + }, + activityType: sp.activity) + let data = try encoder.encode(doc) + let file = outDir.appendingPathComponent("\(sp.name).split.json") + try data.write(to: file) + print("\(doc.id) \(sp.name) (\(sp.ex.count) exercises)") +} diff --git a/Workouts/Resources/StarterSplits/Bodyweight Core.split.json b/Workouts/Resources/StarterSplits/Bodyweight Core.split.json new file mode 100644 index 0000000..a387ae3 --- /dev/null +++ b/Workouts/Resources/StarterSplits/Bodyweight Core.split.json @@ -0,0 +1,121 @@ +{ + "activityType" : 3, + "color" : "teal", + "createdAt" : "2020-01-01T00:00:00Z", + "exercises" : [ + { + "category" : 1, + "durationSeconds" : 0, + "id" : "01DXF6DT00AHY49HPHA00JGDQY", + "loadType" : 0, + "name" : "Cat-Cow", + "order" : 0, + "reps" : 10, + "sets" : 1, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 1, + "durationSeconds" : 0, + "id" : "01DXF6DT0021S72PRVXDA0HCNB", + "loadType" : 0, + "name" : "Dead Bug", + "order" : 1, + "reps" : 8, + "sets" : 1, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 1, + "durationSeconds" : 0, + "id" : "01DXF6DT00K2AHTEHR3HRVKGEG", + "loadType" : 0, + "name" : "Bird Dog", + "order" : 2, + "reps" : 8, + "sets" : 1, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 45, + "id" : "01DXF6DT00V256F3TPS99MCP5F", + "loadType" : 2, + "name" : "Plank", + "order" : 3, + "reps" : 0, + "sets" : 3, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 30, + "id" : "01DXF6DT007YMYCAYYNJEFWTQ5", + "loadType" : 2, + "name" : "Hollow Body Hold", + "order" : 4, + "reps" : 0, + "sets" : 3, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 30, + "id" : "01DXF6DT00WXCASZG1VEZRPT0Y", + "loadType" : 2, + "name" : "Side Plank", + "order" : 5, + "reps" : 0, + "sets" : 3, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00VMWCDF9QCB28WR20", + "loadType" : 0, + "name" : "Leg Raises", + "order" : 6, + "reps" : 12, + "sets" : 3, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00MJH9N622QTMM7XB3", + "loadType" : 0, + "name" : "Bird Dog", + "order" : 7, + "reps" : 8, + "sets" : 3, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00Z98NR23ESEEKGQH1", + "loadType" : 0, + "name" : "Reverse Crunch", + "order" : 8, + "reps" : 12, + "sets" : 3, + "weight" : 0, + "weightReminderWeeks" : 2 + } + ], + "id" : "01DXF6DT00RV99WG172YFW4NKA", + "name" : "Bodyweight Core", + "order" : 3, + "schemaVersion" : 1, + "systemImage" : "figure.core.training", + "updatedAt" : "2020-01-01T00:00:00Z" +} \ No newline at end of file diff --git a/Workouts/Resources/StarterSplits/Core.split.json b/Workouts/Resources/StarterSplits/Core.split.json new file mode 100644 index 0000000..0f3bdb3 --- /dev/null +++ b/Workouts/Resources/StarterSplits/Core.split.json @@ -0,0 +1,37 @@ +{ + "activityType" : 3, + "color" : "orange", + "createdAt" : "2020-01-01T00:00:00Z", + "exercises" : [ + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00C4K0NY1NRCFPB3XY", + "loadType" : 1, + "name" : "Abdominal", + "order" : 0, + "reps" : 10, + "sets" : 4, + "weight" : 0, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT001AY3SXTEYHA94MHZ", + "loadType" : 1, + "name" : "Rotary", + "order" : 1, + "reps" : 10, + "sets" : 4, + "weight" : 0, + "weightReminderWeeks" : 2 + } + ], + "id" : "01DXF6DT001MA0TM7FHJZT098Z", + "name" : "Core", + "order" : 1, + "schemaVersion" : 1, + "systemImage" : "figure.core.training", + "updatedAt" : "2020-01-01T00:00:00Z" +} \ No newline at end of file diff --git a/Workouts/Resources/StarterSplits/Lower Body.split.json b/Workouts/Resources/StarterSplits/Lower Body.split.json new file mode 100644 index 0000000..460a15f --- /dev/null +++ b/Workouts/Resources/StarterSplits/Lower Body.split.json @@ -0,0 +1,61 @@ +{ + "activityType" : 0, + "color" : "green", + "createdAt" : "2020-01-01T00:00:00Z", + "exercises" : [ + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00GV3336R9C25W4X2A", + "loadType" : 1, + "name" : "Abductor", + "order" : 0, + "reps" : 10, + "sets" : 4, + "weight" : 140, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00Z28DJ7PSP7Q11CJH", + "loadType" : 1, + "name" : "Adductor", + "order" : 1, + "reps" : 10, + "sets" : 4, + "weight" : 140, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00D53Q4QMWAE1BHXR6", + "loadType" : 1, + "name" : "Leg Press", + "order" : 2, + "reps" : 10, + "sets" : 4, + "weight" : 160, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT006HW2KWNA5BCCDCJE", + "loadType" : 1, + "name" : "Leg Curl", + "order" : 3, + "reps" : 10, + "sets" : 4, + "weight" : 70, + "weightReminderWeeks" : 2 + } + ], + "id" : "01DXF6DT006QRF1PMGK17FV505", + "name" : "Lower Body", + "order" : 2, + "schemaVersion" : 1, + "systemImage" : "figure.run", + "updatedAt" : "2020-01-01T00:00:00Z" +} \ No newline at end of file diff --git a/Workouts/Resources/StarterSplits/Upper Body.split.json b/Workouts/Resources/StarterSplits/Upper Body.split.json new file mode 100644 index 0000000..bc3d57f --- /dev/null +++ b/Workouts/Resources/StarterSplits/Upper Body.split.json @@ -0,0 +1,61 @@ +{ + "activityType" : 0, + "color" : "blue", + "createdAt" : "2020-01-01T00:00:00Z", + "exercises" : [ + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00N1B90PA7K5ABWSSP", + "loadType" : 1, + "name" : "Lat Pull Down", + "order" : 0, + "reps" : 10, + "sets" : 4, + "weight" : 110, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00R81QRWGCCQG933FZ", + "loadType" : 1, + "name" : "Tricep Press", + "order" : 1, + "reps" : 10, + "sets" : 4, + "weight" : 100, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT000FX4241G5MPG4WG2", + "loadType" : 1, + "name" : "Chest Press", + "order" : 2, + "reps" : 10, + "sets" : 4, + "weight" : 40, + "weightReminderWeeks" : 2 + }, + { + "category" : 0, + "durationSeconds" : 0, + "id" : "01DXF6DT00AD5TSVJPZZVA2S41", + "loadType" : 1, + "name" : "Seated Row", + "order" : 3, + "reps" : 10, + "sets" : 4, + "weight" : 90, + "weightReminderWeeks" : 2 + } + ], + "id" : "01DXF6DT0038BDC2WC3EVX8ZJ5", + "name" : "Upper Body", + "order" : 0, + "schemaVersion" : 1, + "systemImage" : "figure.strengthtraining.traditional", + "updatedAt" : "2020-01-01T00:00:00Z" +} \ No newline at end of file diff --git a/Workouts/Seed/SeedLibrary.swift b/Workouts/Seed/SeedLibrary.swift new file mode 100644 index 0000000..fb54481 --- /dev/null +++ b/Workouts/Seed/SeedLibrary.swift @@ -0,0 +1,65 @@ +import Foundation +import IndieSync + +/// The bundled immutable starter-split library. Each seed ships as byte-canonical +/// `SplitDocument` JSON in `Resources/StarterSplits/*.split.json`, keyed by a fixed +/// ULID (the shared `01DXF6DT00` prefix, minted from a frozen 2020 timestamp) so +/// every install and device agrees on the same identity. +/// +/// Seeds are never mutated in place: editing one clones it to a fresh random ULID +/// and soft-deletes the seed (see `SyncEngine.save(split:)`). The seed's tombstone +/// is exempt from pruning, so it vetoes resurrection forever; the app bundle — not +/// the stub — is the canonical restore source, since the seed content is immutable. +enum SeedLibrary { + /// One bundled seed: its fixed id, decoded document, and the verbatim bundle + /// bytes. The bytes are written to iCloud unchanged so the same seed is + /// byte-identical on every device — a same-path conflict between two devices + /// seeding at once is then semantically empty (both wrote the same bytes). + struct Seed: Sendable { + let id: String + let doc: SplitDocument + let data: Data + } + + /// Resolves the enclosing bundle in both the app and a hosted XCTest bundle + /// (whose `Bundle.main` is the test runner, not the app), where the flattened + /// resources actually live. + private final class BundleToken {} + + /// Every bundled seed, decoded once and cached, in display order. + static let seeds: [Seed] = { + let bundle = Bundle(for: BundleToken.self) + // XcodeGen flattens resource groups, so the files land in the bundle root as + // ".split.json" — enumerate all json and filter on the compound suffix. + let urls = (bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? []) + .filter { $0.lastPathComponent.hasSuffix(".split.json") } + let loaded: [Seed] = urls.compactMap { url in + guard let data = try? Data(contentsOf: url), + let doc = try? DocumentCoder.decode(SplitDocument.self, from: data) + else { return nil } + return Seed(id: doc.id, doc: doc, data: data) + } + return loaded.sorted { $0.doc.order < $1.doc.order } + }() + + static let seedIDs: Set = Set(seeds.map(\.id)) + + static func isSeed(id: String) -> Bool { seedIDs.contains(id) } + + static func seed(id: String) -> Seed? { seeds.first { $0.id == id } } + + /// True when `doc` is unchanged from its pristine seed in every field except + /// `updatedAt`. The edit sheets stamp `updatedAt` on every Save, so a no-op save + /// would otherwise read as an edit and fork the seed; normalizing both timestamps + /// to a common value before the `==` isolates real changes. A non-seed id has no + /// seed to compare against — treated as pristine (callers gate on `isSeed` first). + static func isPristine(_ doc: SplitDocument) -> Bool { + guard let seed = seed(id: doc.id) else { return true } + var edited = doc + var pristine = seed.doc + let common = Date(timeIntervalSince1970: 0) + edited.updatedAt = common + pristine.updatedAt = common + return edited == pristine + } +} diff --git a/Workouts/Seed/SplitSeeder.swift b/Workouts/Seed/SplitSeeder.swift index 9209650..4462225 100644 --- a/Workouts/Seed/SplitSeeder.swift +++ b/Workouts/Seed/SplitSeeder.swift @@ -1,84 +1,32 @@ -import IndieSync import Foundation import SwiftData -/// Builds the bundled machine-based starter routine (Upper Body / Core / Lower -/// Body). Written on demand (never auto-seeded) through the SyncEngine — an empty -/// cache at launch can't be told apart from an iCloud library that simply hasn't -/// downloaded yet. +/// The bundled immutable starter-split library, brought back on demand. The seeds +/// themselves — fixed ULIDs, byte-canonical bundle JSON — live in `SeedLibrary`; +/// editing one clones it and permanently tombstones the seed (see +/// `SyncEngine.save(split:)`). The true first-run case is handled automatically by +/// `SyncEngine.autoSeedIfEmpty`; this on-demand path (the "Add Starter Splits" +/// button) is for a user who wants the starters back after removing some. enum SplitSeeder { - /// One starter exercise: name plus its default starting weight (lbs). - private struct SeedExercise { - let name: String - let weight: Int - } - - /// One starter split: visual theme, activity type, plus its ordered exercises. - private struct SeedSplit { - let name: String - let color: String - let icon: String - let activity: WorkoutActivityType - let exercises: [SeedExercise] - } - - /// Sets/reps shared by every starter exercise. - private static let defaultSets = 4 - private static let defaultReps = 10 - - /// Starter splits in display order, with sensible machine starting weights. - /// Users adjust weights from the exercise screen. - private static let starterSplits: [SeedSplit] = [ - SeedSplit(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: .traditionalStrength, exercises: [ - SeedExercise(name: "Lat Pull Down", weight: 110), - SeedExercise(name: "Tricep Press", weight: 100), - SeedExercise(name: "Chest Press", weight: 40), - SeedExercise(name: "Seated Row", weight: 90), - ]), - SeedSplit(name: "Core", color: "orange", icon: "figure.core.training", activity: .coreTraining, exercises: [ - SeedExercise(name: "Abdominal", weight: 0), - SeedExercise(name: "Rotary", weight: 0), - ]), - SeedSplit(name: "Lower Body", color: "green", icon: "figure.run", activity: .traditionalStrength, exercises: [ - SeedExercise(name: "Abductor", weight: 140), - SeedExercise(name: "Adductor", weight: 140), - SeedExercise(name: "Leg Press", weight: 160), - SeedExercise(name: "Leg Curl", weight: 70), - ]), - ] - - /// Builds the default split documents (fresh ULIDs each call). - static func defaultSplitDocuments() -> [SplitDocument] { - starterSplits.enumerated().map { order, split in - let exercises = split.exercises.enumerated().map { idx, item in - ExerciseDocument( - id: ULID.make(), name: item.name, order: idx, - sets: defaultSets, reps: defaultReps, weight: item.weight, - loadType: LoadType.weight.rawValue, - durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2 - ) - } - return SplitDocument( - schemaVersion: SplitDocument.currentSchemaVersion, id: ULID.make(), - name: split.name, color: split.color, systemImage: split.icon, order: order, - createdAt: Date(), updatedAt: Date(), exercises: exercises, - activityType: split.activity.rawValue - ) - } - } - - /// Writes any starter splits whose name doesn't already exist, appended after - /// existing splits. Idempotent against double-taps / partial prior seeds. + /// Add every starter split the user doesn't already have. For each seed: + /// • skip if a live split with the same NAME exists — a user's edited clone of + /// "Upper Body" must not be joined by a resurrected seed of the same name; + /// • else if the seed was deleted (tombstoned), restore it (lifting the veto); + /// • else write it fresh. + /// Seed `order` values are fixed (0–3); a collision with a user split's order is + /// an accepted cosmetic tie. Idempotent against double-taps and partial prior seeds. @MainActor static func seedDefaults(into context: ModelContext, using sync: SyncEngine) async { let existing = (try? context.fetch(FetchDescriptor())) ?? [] let existingNames = Set(existing.map(\.name)) - let base = existing.count - let fresh = defaultSplitDocuments().filter { !existingNames.contains($0.name) } - for (offset, var doc) in fresh.enumerated() { - doc.order = base + offset - await sync.save(split: doc) + for seed in SeedLibrary.seeds { + if existingNames.contains(seed.doc.name) { continue } + if await sync.isTombstoned(id: seed.id) { + await sync.restoreSeed(seed) + } else { + await sync.writeSeed(seed) + } } } } diff --git a/Workouts/Sync/SyncEngine.swift b/Workouts/Sync/SyncEngine.swift index 0388720..5df2887 100644 --- a/Workouts/Sync/SyncEngine.swift +++ b/Workouts/Sync/SyncEngine.swift @@ -25,6 +25,23 @@ final class SyncEngine { /// next successful write or full reconcile. private(set) var lastSyncError: String? + /// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still + /// holding the seed's id resolves to the live clone. In-memory only (@Observable + /// notifies on change); not persisted — a seed is edited at most once before it's + /// gone, so nothing needs to survive a relaunch. + private(set) var cloneRedirects: [String: String] = [:] + + /// Follow the redirect chain from `id` to the current live split id. A seed + /// redirects at most once, but the loop tolerates (and breaks) any chain or cycle. + func currentSplitID(for id: String) -> String { + var current = id + var seen: Set = [current] + while let next = cloneRedirects[current], seen.insert(next).inserted { + current = next + } + return current + } + /// Called after the cache changes (local or remote). The watch bridge uses /// this to push fresh state to the watch. var onCacheChanged: (() -> Void)? @@ -136,6 +153,8 @@ final class SyncEngine { await reconcile() startMonitoring(documentsURL: store.rootURL) cleanupOldStubs() + // Off the connect path so opening the gate isn't delayed by the settle wait. + Task { await self.autoSeedIfEmpty() } } /// Invoked from the connecting screen when the user chooses not to keep @@ -167,7 +186,12 @@ final class SyncEngine { switch event { case .added(let path), .modified(let path): if path.hasPrefix("Stubs/") { - deleteCachedEntity(id: idFromStubPath(path)) + let id = idFromStubPath(path) + deleteCachedEntity(id: id) + // A stub can arrive before (or instead of) the deleting device's + // live-file removal; drop any live file for this id so the delete + // takes effect and reconcile can't re-import it. + await removeLiveFile(forID: id) } else { await importFile(relativePath: path) } @@ -191,6 +215,14 @@ final class SyncEngine { /// observer re-applies idempotently if it does fire) and lets open phone screens /// reflect watch progress live. func ingestFromWatch(_ doc: WorkoutDocument) async { + // A workout deleted on the phone leaves a tombstone; a watch that missed the + // delete may still push its stale copy. Honor the veto — never resurrect it — + // and re-push authoritative state (via onCacheChanged → pushAll) so the watch + // replaces its stale set and drops the deleted workout. + if let tombstones, await tombstones.stubExists(id: doc.id) { + onCacheChanged?() + return + } await save(workout: doc) CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context) do { try context.save() } catch { report("Cache save failed", error) } @@ -199,8 +231,22 @@ final class SyncEngine { // MARK: - Public CRUD (write path: files only) - func save(split doc: SplitDocument) async { - guard let store else { return } + /// Returns the *effective* id of the split that was written — normally `doc.id`, + /// but the clone's fresh id when an edited seed forks. Open views follow the swap + /// via `currentSplitID(for:)`. + @discardableResult + func save(split doc: SplitDocument) async -> String { + guard let store else { return doc.id } + + // Seeds are immutable: a real edit forks the seed into a user-owned split and + // soft-deletes the original, keeping the curated seed intact and restorable. A + // pristine (no-op) save must NOT fork — the edit sheets stamp `updatedAt` + // unconditionally, so merely opening and saving a seed's editor without a change + // would otherwise clone it. + if SeedLibrary.isSeed(id: doc.id), !SeedLibrary.isPristine(doc) { + return await cloneSeedOnEdit(doc) + } + do { try await store.write(doc, to: doc.relativePath) lastSyncError = nil @@ -208,6 +254,37 @@ final class SyncEngine { report("Failed to save split", error) } // Cache updates reactively via the monitor. + return doc.id + } + + /// Fork an edited seed into a user-owned split. Writes the clone under a fresh + /// ULID and upserts it into the cache immediately (same rationale as + /// `ingestFromWatch`: a same-process write doesn't reliably wake the metadata + /// observer, and open screens must see the clone the moment `save` returns), then + /// soft-deletes the seed and drops its cache entity. Records the id redirect so a + /// view still holding the seed's id follows the identity swap. + private func cloneSeedOnEdit(_ doc: SplitDocument) async -> String { + guard let store else { return doc.id } + var clone = doc + clone.id = ULID.make() + clone.createdAt = Date() + // Exercise ids are kept as-is — they only need uniqueness within the document. + do { + try await store.write(clone, to: clone.relativePath) + CacheMapper.upsertSplit(clone, relativePath: clone.relativePath, into: context) + // Soft-delete the seed (writes its veto stub, then removes the live file), + // then evict its now-orphaned cache entity. + await softDelete(id: doc.id, kind: "split", livePath: doc.relativePath) + deleteCachedEntity(id: doc.id) + try context.save() + cloneRedirects[doc.id] = clone.id + lastSyncError = nil + onCacheChanged?() + return clone.id + } catch { + report("Failed to fork edited starter split", error) + return doc.id + } } func save(workout doc: WorkoutDocument) async { @@ -361,17 +438,113 @@ final class SyncEngine { (path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "") } + /// Remove any live data file for `id` when its tombstone arrives. Matches by + /// filename identity across every non-`Stubs/` path — deliberately NOT + /// `resolveExistingPath(forID:)`, which would also match the stub itself. + private func removeLiveFile(forID id: String) async { + guard let store else { return } + let target = "\(id).json" + let livePaths = await store.list().filter { + !$0.hasPrefix("Stubs/") && ($0 as NSString).lastPathComponent == target + } + for path in livePaths { + try? await store.remove(at: path) + } + } + // MARK: - Maintenance private func cleanupOldStubs() { guard let tombstones else { return } + let exempt = SeedLibrary.seedIDs Task.detached(priority: .utility) { // Downloads evicted stubs to read their deletedAt, prunes past the - // 30-day grace period. - try? await tombstones.prune() + // 30-day grace period. Seed tombstones veto resurrection permanently, so + // they're exempt and never pruned. + try? await tombstones.prune(exempting: exempt) } } + // MARK: - Seeding + + /// True when the container holds no documents at all — not one data file and not + /// one tombstone. A user with only workouts, or any tombstone (i.e. a prior + /// delete), is not a new user, so we never auto-seed over them. Placeholder-aware + /// by construction (`store.list()` sees evicted files too). + private func containerEmpty() async -> Bool { + guard let store else { return false } + return await store.list().isEmpty + } + + /// Write the starter library exactly once, only into a verifiably empty container + /// (the true first run). Deferred and re-checked after a settle delay: an empty + /// listing right after connect can just mean the metadata index hasn't populated + /// yet, and seeding into that illusion would duplicate a library that's about to + /// arrive. + private func autoSeedIfEmpty() async { + guard let store, await containerEmpty() else { return } + // Give the metadata index a chance to surface existing files before we conclude + // the account is genuinely new. + try? await Task.sleep(for: .seconds(5)) + guard iCloudStatus == .available, await containerEmpty() else { return } + + for seed in SeedLibrary.seeds { + do { + // Verbatim bundle bytes: byte-identical files across devices make a + // same-path conflict (two devices seeding at once) semantically empty. + try await store.writeData(seed.data, to: seed.doc.relativePath) + CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) + } catch { + report("Failed to seed \(seed.doc.name)", error) + } + } + do { try context.save() } catch { report("Cache save failed", error) } + onCacheChanged?() + } + + /// Add one seed fresh: write its verbatim bundle bytes and upsert the cache. The + /// on-demand "Add Starter Splits" path uses this for a seed that's neither present + /// nor tombstoned. + func writeSeed(_ seed: SeedLibrary.Seed) async { + guard let store else { return } + do { + try await store.writeData(seed.data, to: seed.doc.relativePath) + CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) + try context.save() + lastSyncError = nil + } catch { + report("Failed to add starter split \(seed.doc.name)", error) + } + onCacheChanged?() + } + + /// Bring a previously-deleted seed back: lift its veto stub, then write its + /// verbatim bundle bytes. The bundle is the canonical restore source (seed content + /// is immutable), so this restores from the bundle rather than reconstructing from + /// the stub — the seed's stub is only ever a minimal veto marker. The stub is + /// removed first so neither a racing observer event nor a reconcile re-vetoes the + /// file we're about to write while the stub still exists. + func restoreSeed(_ seed: SeedLibrary.Seed) async { + guard let store, let tombstones else { return } + do { + try await tombstones.removeStub(at: "\(seed.id).json") + try await store.writeData(seed.data, to: seed.doc.relativePath) + CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) + try context.save() + lastSyncError = nil + } catch { + report("Failed to restore starter split \(seed.doc.name)", error) + } + onCacheChanged?() + } + + /// Whether a tombstone exists for `id` (placeholder-aware). The on-demand seed + /// path checks this to restore rather than re-create a deleted seed. + func isTombstoned(id: String) async -> Bool { + guard let tombstones else { return false } + return await tombstones.stubExists(id: id) + } + // MARK: - Error Reporting /// Record a non-fatal sync failure: log it and publish it as `lastSyncError` diff --git a/Workouts/Views/Exercises/ExerciseListView.swift b/Workouts/Views/Exercises/ExerciseListView.swift index 61aebac..769850f 100644 --- a/Workouts/Views/Exercises/ExerciseListView.swift +++ b/Workouts/Views/Exercises/ExerciseListView.swift @@ -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 { diff --git a/Workouts/Views/Splits/SplitAddEditView.swift b/Workouts/Views/Splits/SplitAddEditView.swift index ad1f89e..34ab075 100644 --- a/Workouts/Views/Splits/SplitAddEditView.swift +++ b/Workouts/Views/Splits/SplitAddEditView.swift @@ -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 diff --git a/Workouts/Views/Splits/SplitDetailView.swift b/Workouts/Views/Splits/SplitDetailView.swift index 9dbc2e8..62d137d 100644 --- a/Workouts/Views/Splits/SplitDetailView.swift +++ b/Workouts/Views/Splits/SplitDetailView.swift @@ -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 diff --git a/WorkoutsTests/SeedLibraryTests.swift b/WorkoutsTests/SeedLibraryTests.swift new file mode 100644 index 0000000..094766b --- /dev/null +++ b/WorkoutsTests/SeedLibraryTests.swift @@ -0,0 +1,111 @@ +import Foundation +import SwiftData +import Testing +import IndieSync +@testable import Workouts + +/// Locks the bundled starter-split library end to end: the catalog decodes and +/// orders correctly, the pinned seed ids never drift, a pristine save re-emits +/// byte-identical JSON (no phantom iCloud churn), every seed passes the +/// forward-compatibility gate, and `isPristine` tracks edits correctly through +/// both the raw document and a SwiftData cache round-trip. +struct SeedLibraryTests { + + @Test func catalogLoadsFourSeedsInOrder() { + let seeds = SeedLibrary.seeds + #expect(seeds.count == 4) + #expect(seeds.map(\.doc.order) == [0, 1, 2, 3]) + #expect(seeds.map(\.doc.name) == ["Upper Body", "Core", "Lower Body", "Bodyweight Core"]) + } + + /// These ids are minted from a frozen 2020 timestamp and are already shipped + /// inside installed app bundles. Changing any of them breaks cross-device + /// convergence (two devices would mint different ids for "the same" starter + /// split) and voids the permanent-tombstone exemption that lets an + /// edited-then-deleted seed's stub veto resurrection forever — regenerate + /// only with extreme care, and never for a seed that has already shipped. + @Test func seedIDsArePinned() { + #expect(SeedLibrary.seedIDs == [ + "01DXF6DT0038BDC2WC3EVX8ZJ5", // Upper Body + "01DXF6DT001MA0TM7FHJZT098Z", // Core + "01DXF6DT006QRF1PMGK17FV505", // Lower Body + "01DXF6DT00RV99WG172YFW4NKA", // Bodyweight Core + ]) + } + + /// A pristine save must re-emit the exact bundle bytes, or iCloud would see a + /// modification on every install/upgrade that touches a never-edited seed. + @Test func encodingReproducesBundleBytesExactly() throws { + for seed in SeedLibrary.seeds { + #expect(try DocumentCoder.encode(seed.doc) == seed.data) + } + } + + @Test func allSeedsAreReadableAtCurrentSchemaVersion() { + for seed in SeedLibrary.seeds { + #expect(seed.doc.isReadable) + #expect(seed.doc.schemaVersion == SplitDocument.currentSchemaVersion) + } + } + + @Test func pristineSeedsAreThemselvesPristine() { + for seed in SeedLibrary.seeds { + #expect(SeedLibrary.isPristine(seed.doc)) + } + } + + /// Edit sheets stamp `updatedAt` on every Save, so a no-op save must not read + /// as an edit — otherwise saving without changing anything would fork the seed. + @Test func pristineIgnoresUpdatedAtOnlyChange() throws { + let seed = try #require(SeedLibrary.seeds.first) + var edited = seed.doc + edited.updatedAt = Date() + #expect(SeedLibrary.isPristine(edited)) + } + + @Test func pristineDetectsExerciseWeightChange() throws { + let seed = try #require(SeedLibrary.seeds.first) + var edited = seed.doc + edited.exercises[0].weight += 5 + #expect(!SeedLibrary.isPristine(edited)) + } + + @Test func pristineDetectsNameChange() throws { + let seed = try #require(SeedLibrary.seeds.first) + var edited = seed.doc + edited.name = "Renamed" + #expect(!SeedLibrary.isPristine(edited)) + } + + /// A non-seed id has no seed to compare against; `isPristine` defaults to + /// true since callers are expected to gate on `isSeed` before asking. + @Test func pristineDefaultsTrueForNonSeedID() throws { + let seed = try #require(SeedLibrary.seeds.first) + var notASeed = seed.doc + notASeed.id = ULID.make() + #expect(SeedLibrary.isPristine(notASeed)) + } + + /// Round-trips every seed through the SwiftData cache the way the metadata + /// observer would (document → `CacheMapper.upsertSplit` → entity), then + /// rebuilds a document from that entity the way the write path would. The + /// result must still read as pristine, or every user's very first cache + /// rebuild would silently fork their starter splits. + @MainActor + @Test func mapperRoundTripPreservesPristineness() throws { + let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self]) + // cloudKitDatabase: .none matches WorkoutsModelContainer — without it SwiftData + // applies CloudKit schema validation (all relationships optional), which the + // cache entities intentionally don't satisfy. + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) + let container = try ModelContainer(for: schema, configurations: [config]) + let context = ModelContext(container) + + for seed in SeedLibrary.seeds { + CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context) + let entity = try #require(CacheMapper.fetchSplit(id: seed.id, in: context)) + let rebuilt = SplitDocument(from: entity) + #expect(SeedLibrary.isPristine(rebuilt)) + } + } +} diff --git a/project.yml b/project.yml index fcc88da..1ad1935 100644 --- a/project.yml +++ b/project.yml @@ -21,7 +21,7 @@ packages: from: "0.2.2" IndieSync: url: https://git.rzen.dev/rzen/indie-sync.git - from: "0.1.0" + from: "0.3.0" Yams: url: https://github.com/jpsim/Yams from: "6.0.0"