Pick any exercise from the full library while a workout is running, not just the ones in its split. The new exercise's plan is seeded from the most recent log of that exercise, else the library's authored Defaults line, else a plain 3x10. Adds a searchable two-section picker sheet and a **Defaults:** bullet to all 47 library reference pages. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
285 lines
9.9 KiB
Swift
285 lines
9.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 IndieSync
|
||
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct ExerciseListView: 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: 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
|
||
@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
|
||
|
||
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
|
||
|
||
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)
|
||
}
|
||
}
|
||
.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) {
|
||
guard let split else { return }
|
||
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() {
|
||
guard let split else { return }
|
||
let startDate = Date()
|
||
let logs = split.exercisesArray.enumerated().map { i, ex in
|
||
WorkoutLogDocument(planFrom: ExerciseDocument(from: ex), order: i, 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]) {
|
||
guard let split else { return }
|
||
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 -> ExerciseDocument in
|
||
// Seed from the library's authored `**Defaults:**` when available,
|
||
// falling back to a plain 3×10 weighted guess; weight always starts
|
||
// at 0 (there's no prior lift to seed it from).
|
||
let defaults = ExerciseInfoLibrary.info(for: exName)?.defaults
|
||
return ExerciseDocument(
|
||
id: ULID.make(), name: exName, order: base + i,
|
||
sets: defaults?.sets ?? 3, reps: defaults?.reps ?? 10, weight: 0,
|
||
loadType: (defaults?.loadType ?? .weight).rawValue,
|
||
durationSeconds: defaults?.durationSeconds ?? 0
|
||
)
|
||
}
|
||
doc.exercises.append(contentsOf: newDocs)
|
||
doc.updatedAt = Date()
|
||
Task { await sync.save(split: doc) }
|
||
}
|
||
|
||
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 {
|
||
doc.exercises[i].order = i
|
||
}
|
||
doc.updatedAt = Date()
|
||
Task { await sync.save(split: doc) }
|
||
}
|
||
}
|