85d0eaddbb
Replace Core Data + NSPersistentCloudKitContainer + App-Group store + WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents architecture: - iCloud Drive JSON documents are the sole source of truth (one file per aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a rebuildable SwiftData cache populated only by an NSMetadataQuery observer and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate. - Shared model layer (ULID, Codable *Documents + stateless mappers, @Model cache entities, SwiftData container) compiled into both targets. - New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone is the sole writer of iCloud Drive, the watch round-trips documents. - AppServices DI + iCloud-required root gate; Swift 6 strict concurrency. - Starter splits generated on demand from the bundled YAML catalogs. - Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments entitlement (drop CloudKit/App Group/aps-environment). - Duration stored as Int seconds (was a Date epoch hack); fix workout end-on-create, undismissable delete dialog, toolbar-hiding nav stacks, and the Settings placeholder. - Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the Scripts/ TestFlight pipeline (release.sh + ASC API scripts). MARKETING_VERSION 2.0.
204 lines
6.9 KiB
Swift
204 lines
6.9 KiB
Swift
//
|
||
// ExerciseListView.swift
|
||
// Workouts
|
||
//
|
||
// Created by rzen on 7/18/25 at 8:38 AM.
|
||
//
|
||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||
//
|
||
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct ExerciseListView: View {
|
||
@Environment(SyncEngine.self) private var sync
|
||
@Environment(\.modelContext) private var modelContext
|
||
|
||
var split: Split
|
||
|
||
@State private var showingAddSheet: Bool = false
|
||
@State private var itemToEdit: Exercise? = nil
|
||
@State private var itemToDelete: Exercise? = nil
|
||
/// ID of the just-created workout; drives programmatic navigation once the
|
||
/// cache observer delivers the entity a beat after the file write.
|
||
@State private var pendingWorkoutID: String? = nil
|
||
@State private var resolvedWorkout: Workout? = nil
|
||
|
||
var body: some View {
|
||
Form {
|
||
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 {
|
||
showingAddSheet = true
|
||
} label: {
|
||
ListItem(title: "Add Exercise")
|
||
}
|
||
} else {
|
||
Text("No exercises added yet.")
|
||
Button(action: { showingAddSheet.toggle() }) {
|
||
ListItem(title: "Add Exercise")
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle(split.name)
|
||
.toolbar {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button("Start This Split") {
|
||
startWorkout()
|
||
}
|
||
.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)
|
||
}, allowMultiSelect: true)
|
||
}
|
||
.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?")
|
||
}
|
||
}
|
||
|
||
// MARK: - Helpers
|
||
|
||
private func pollForWorkout(id: String) {
|
||
Task {
|
||
// Give the file→observer→cache loop a moment to complete (typically < 1 s).
|
||
for _ in 0..<20 {
|
||
try? await Task.sleep(for: .milliseconds(150))
|
||
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
|
||
resolvedWorkout = workout
|
||
pendingWorkoutID = nil
|
||
return
|
||
}
|
||
}
|
||
// If still not available after ~3 s, clear the pending ID silently.
|
||
pendingWorkoutID = 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 startWorkout() {
|
||
let start = Date()
|
||
let logs = split.exercisesArray.enumerated().map { i, ex in
|
||
WorkoutLogDocument(
|
||
id: ULID.make(), exerciseName: ex.name, order: i,
|
||
sets: ex.sets, reps: ex.reps, weight: ex.weight,
|
||
loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds,
|
||
currentStateIndex: 0, completed: false,
|
||
status: WorkoutStatus.notStarted.rawValue,
|
||
notes: nil, date: start
|
||
)
|
||
}
|
||
let doc = WorkoutDocument(
|
||
schemaVersion: WorkoutDocument.currentSchema,
|
||
id: ULID.make(),
|
||
splitID: split.id,
|
||
splitName: split.name,
|
||
start: start,
|
||
end: nil,
|
||
status: WorkoutStatus.notStarted.rawValue,
|
||
createdAt: start,
|
||
updatedAt: start,
|
||
logs: logs
|
||
)
|
||
Task {
|
||
await sync.save(workout: doc)
|
||
pendingWorkoutID = doc.id
|
||
}
|
||
}
|
||
|
||
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) }
|
||
}
|
||
|
||
private func deleteExercise(_ exercise: Exercise) {
|
||
var doc = SplitDocument(from: split)
|
||
doc.exercises.removeAll { $0.id == exercise.id }
|
||
for i in doc.exercises.indices {
|
||
doc.exercises[i].order = i
|
||
}
|
||
doc.updatedAt = Date()
|
||
Task { await sync.save(split: doc) }
|
||
}
|
||
}
|