Files
workouts/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift
T
rzen 2c1e4759ae Add machine comfort settings and tighten the document schemas
Machine-based exercises now carry ordered name/value comfort settings
(seat height, back-rest position, ...) on both ExerciseDocument and, as a
plan-time snapshot, WorkoutLogDocument — the optional array doubles as the
machine flag. Editable from the exercise editor's new Machine section and
from the workout row's settings sheet, whose mid-workout edits write back
to the originating split (workout saved first, so seed clone-on-edit
repointing can't clobber the log edit).

Schema tightening rides the same rev: splits bump to v2 (weight-reminder
fields and the unused exercise category removed), workouts to v3 (the
derived `completed` flag removed; status is the single source). Starter
seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema
bumped to rebuild. SCHEMA.md documents the shapes.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
2026-07-06 16:29:44 -04:00

247 lines
9.1 KiB
Swift

//
// WorkoutLogsView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 6:52 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
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(planFrom: ExerciseDocument(from: exercise), order: index, date: startDate)
}
// A freshly started workout has no `end` — only completion stamps it.
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) }
// 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()
}
}