Files
workouts/Workouts/Views/Exercises/ExerciseListView.swift
T
rzen 1f2df491db Migrate the sync file layer onto the IndieSync package
Replaces the app-local copies of the extracted storage core with the
IndieSync 0.1.0 package (pinned from the new tag): ICloudFileManager ->
DocumentFileStore + TombstoneStore, ICloudFileMonitor ->
MetadataObserver (batched events with the content-date churn gate),
and the shared ULID / DocumentCoder / Tombstone / VersionedDocument
pieces. SyncEngine stays app-specific and is rewired onto the package
types; documents rename currentSchema -> currentSchemaVersion to adopt
the package protocol. ULID.make() remains as a shim minting the string
form the app keys on.

Behavior-preserving: identical JSON bytes (same coder config), same
stub wire format (kind encodes as the same strings), same soft-delete
ordering, same 30-day prune. WorkoutDocument keeps its local-calendar
month bucketing rather than adopting TimeBucketedLayout's UTC paths --
changing derivation would strand existing files. The watch target
links the package too (ULID + DocumentCoder via Shared); iOS, watchOS,
and widget targets all build.
2026-07-05 08:04:29 -04:00

258 lines
8.8 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// ExerciseListView.swift
// Workouts
//
// Created by rzen on 7/18/25 at 8:38 AM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
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
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@State private var showingActivePrompt = false
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
private var activeWorkouts: [Workout] {
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
}
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) × \(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 {
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") {
confirmAndStart()
}
.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?")
}
.confirmationDialog(
activePromptTitle,
isPresented: $showingActivePrompt,
titleVisibility: .visible
) {
Button("End Current & Start New") { endActiveThenStart() }
Button("Start in Parallel") { start() }
Button("Cancel", role: .cancel) { showingActivePrompt = false }
} message: {
Text(activePromptMessage)
}
}
// MARK: - Helpers
private var activePromptTitle: String {
activeWorkouts.count == 1 ? "Workout in Progress" : "\(activeWorkouts.count) Workouts in Progress"
}
private var activePromptMessage: String {
let n = activeWorkouts.count
let those = n == 1 ? "it" : "them"
return "You already have \(n == 1 ? "a workout" : "\(n) workouts") going. End \(those) first, or run this one alongside."
}
/// Prompt before starting if other workouts are still going; otherwise start straight away.
private func confirmAndStart() {
if activeWorkouts.isEmpty {
start()
} else {
showingActivePrompt = true
}
}
/// End every in-flight workout (keeping its progress), then start this split.
private func endActiveThenStart() {
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
showingActivePrompt = false
Task {
for var doc in toEnd {
doc.endKeepingProgress()
await sync.save(workout: doc)
}
}
start()
}
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 start() {
let startDate = 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: startDate
)
}
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: ULID.make(),
splitID: split.id,
splitName: split.name,
start: startDate,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: startDate,
updatedAt: startDate,
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) }
}
}