The "Start This Split" button on a split's exercise list minted and saved the workout but never called WorkoutLauncher, so the Apple Watch never came up when starting from there — only the home-screen split picker launched it. Inject AppServices into ExerciseListView and call launchWatchWorkout from start(), mirroring the picker path. Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
266 lines
9.4 KiB
Swift
266 lines
9.4 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(AppServices.self) private var services
|
||
@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 (see
|
||
/// `navigatesToStartedWorkout`).
|
||
@State private var pendingWorkoutID: String? = 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 into the workout's log screen once the entity appears in the cache.
|
||
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
|
||
}
|
||
|
||
@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 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,
|
||
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance
|
||
)
|
||
Task {
|
||
await sync.save(workout: doc)
|
||
pendingWorkoutID = doc.id
|
||
}
|
||
// Bring the Apple Watch up into the session so the user can run it from the wrist,
|
||
// tagged with the split's activity type — mirroring the split-picker start path.
|
||
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
|
||
}
|
||
|
||
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) }
|
||
}
|
||
}
|