Closes the last open loop of the HealthKit feature (decision C from HealthKit-Delete-Loop.md, now retired): the history-list delete dialog gains a "Delete + Remove from Apple Health" option, shown only when the workout has a Health record. Removal is best-effort via the workout's stored HKWorkout UUID; watch-recorded workouts are authored by the watch app's separate HealthKit source and may be refused, so the dialog notes they may need deleting in the Health app instead. Discarding an in-progress workout is unaffected (no Health record exists yet).
260 lines
9.5 KiB
Swift
260 lines
9.5 KiB
Swift
//
|
|
// WorkoutLogsView.swift
|
|
// Workouts
|
|
//
|
|
// Created by rzen on 7/13/25 at 6:52 PM.
|
|
//
|
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct WorkoutLogsView: View {
|
|
@Environment(SyncEngine.self) private var sync
|
|
@Environment(AppServices.self) private var services
|
|
|
|
@Query(sort: \Workout.start, order: .reverse)
|
|
private var workouts: [Workout]
|
|
|
|
@State private var showingSplitPicker = false
|
|
@State private var showingSettings = false
|
|
@State private var itemToDelete: Workout?
|
|
|
|
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(workouts) { workout in
|
|
NavigationLink {
|
|
WorkoutLogListView(workout: workout)
|
|
} label: {
|
|
CalendarListItem(
|
|
date: workout.start,
|
|
title: workout.splitName ?? Split.unnamed,
|
|
subtitle: getSubtitle(for: workout),
|
|
subtitle2: workout.statusName
|
|
)
|
|
}
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
|
Button {
|
|
itemToDelete = workout
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
.tint(.red)
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if workouts.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Workouts Yet",
|
|
systemImage: "list.bullet.clipboard",
|
|
description: Text("Start a new workout from one of your splits.")
|
|
)
|
|
}
|
|
}
|
|
.navigationTitle("Workout Logs")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button {
|
|
showingSettings = true
|
|
} label: {
|
|
Image(systemName: "gearshape.2")
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Start New") {
|
|
showingSplitPicker.toggle()
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showingSettings) {
|
|
SettingsView()
|
|
}
|
|
.sheet(isPresented: $showingSplitPicker) {
|
|
SplitPickerSheet()
|
|
}
|
|
.confirmationDialog(
|
|
"Delete Workout?",
|
|
isPresented: Binding(
|
|
get: { itemToDelete != nil },
|
|
set: { if !$0 { itemToDelete = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: itemToDelete
|
|
) { workout in
|
|
Button("Delete", role: .destructive) {
|
|
Task { await sync.delete(workout: workout) }
|
|
itemToDelete = nil
|
|
}
|
|
// Only offered when the workout actually has a Health record. Capture
|
|
// the UUID before the delete prunes the cache entity.
|
|
if let healthUUID = workout.metricHealthKitWorkoutUUID {
|
|
Button("Delete + Remove from Apple Health", role: .destructive) {
|
|
services.workoutHealthWriter.deleteFromHealth(uuidString: healthUUID)
|
|
Task { await sync.delete(workout: workout) }
|
|
itemToDelete = nil
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
itemToDelete = nil
|
|
}
|
|
} message: { workout in
|
|
if workout.metricSourceRaw == MetricSource.watch.rawValue {
|
|
Text("This workout was recorded by Apple Watch; if it can't be removed from Apple Health here, delete it in the Health app.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func getSubtitle(for workout: Workout) -> String {
|
|
if workout.status == .completed, let endDate = workout.end {
|
|
return workout.start.humanTimeInterval(to: endDate)
|
|
} else {
|
|
return workout.start.formattedDate()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Split Picker Sheet
|
|
|
|
struct SplitPickerSheet: View {
|
|
@Environment(SyncEngine.self) private var sync
|
|
@Environment(AppServices.self) private var services
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
|
|
private var splits: [Split]
|
|
|
|
@Query(sort: \Workout.start, order: .reverse)
|
|
private var workouts: [Workout]
|
|
|
|
/// Set when the user picks a split while other workouts are still going — drives the
|
|
/// "end the current one(s) or run in parallel?" prompt.
|
|
@State private var splitAwaitingConfirmation: Split?
|
|
|
|
private var activeWorkouts: [Workout] {
|
|
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(splits) { split in
|
|
Button {
|
|
confirmAndStart(with: split)
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: split.systemImage)
|
|
.foregroundColor(Color.color(from: split.color))
|
|
Text(split.name)
|
|
Spacer()
|
|
Text("\(split.exercisesArray.count) exercises")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Select a Split")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
activePromptTitle,
|
|
isPresented: Binding(
|
|
get: { splitAwaitingConfirmation != nil },
|
|
set: { if !$0 { splitAwaitingConfirmation = nil } }
|
|
),
|
|
titleVisibility: .visible,
|
|
presenting: splitAwaitingConfirmation
|
|
) { split in
|
|
Button("End Current & Start New") { endActiveThenStart(with: split) }
|
|
Button("Start in Parallel") { start(with: split) }
|
|
Button("Cancel", role: .cancel) { splitAwaitingConfirmation = nil }
|
|
} message: { _ in
|
|
Text(activePromptMessage)
|
|
}
|
|
}
|
|
}
|
|
|
|
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(with split: Split) {
|
|
if activeWorkouts.isEmpty {
|
|
start(with: split)
|
|
} else {
|
|
splitAwaitingConfirmation = split
|
|
}
|
|
}
|
|
|
|
/// End every in-flight workout (keeping its progress), then start the picked split.
|
|
private func endActiveThenStart(with split: Split) {
|
|
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
|
|
splitAwaitingConfirmation = nil
|
|
Task {
|
|
for var doc in toEnd {
|
|
doc.endKeepingProgress()
|
|
await sync.save(workout: doc)
|
|
}
|
|
}
|
|
start(with: split)
|
|
}
|
|
|
|
private func start(with split: Split) {
|
|
let startDate = Date()
|
|
let logs = split.exercisesArray.enumerated().map { index, exercise in
|
|
WorkoutLogDocument(
|
|
id: ULID.make(),
|
|
exerciseName: exercise.name,
|
|
order: index,
|
|
sets: exercise.sets,
|
|
reps: exercise.reps,
|
|
weight: exercise.weight,
|
|
loadType: exercise.loadType,
|
|
durationSeconds: exercise.durationTotalSeconds,
|
|
currentStateIndex: 0,
|
|
completed: false,
|
|
status: WorkoutStatus.notStarted.rawValue,
|
|
notes: nil,
|
|
date: startDate
|
|
)
|
|
}
|
|
|
|
// A freshly started workout has no `end` — only completion stamps it.
|
|
let doc = WorkoutDocument(
|
|
schemaVersion: WorkoutDocument.currentSchema,
|
|
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) }
|
|
// Bring the Apple Watch up into the session so the user can run it from the wrist,
|
|
// tagged with the split's activity type so the saved Health workout is categorized.
|
|
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
|
|
dismiss()
|
|
}
|
|
}
|