d5915a9552
- Redesign the watch app into an active-workout runner: a root gate shows the in-progress workout's exercises or prompts to start one on iPhone, and each exercise runs as a horizontally-paged HIIT cycle (count-up work, count-down rest with final-three-second haptics + auto-advance, One More / Done on the last set). Replaces the old history list. - Add a configurable rest-between-sets duration in iPhone Settings (default 45s), synced to the watch over WatchConnectivity. - Launch the watch app into the session when a workout starts on the phone via HealthKit (startWatchApp); the watch runs an HKWorkoutSession for foreground runtime and ends it when the workout finishes. Adds the HealthKit entitlement + Health usage strings on both targets and WKBackgroundModes on the watch. Claude-Session: https://claude.ai/code/session_018gg69MaUetDNzWzBXisfMV
187 lines
6.2 KiB
Swift
187 lines
6.2 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
|
|
|
|
@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
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
itemToDelete = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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]
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(splits) { split in
|
|
Button {
|
|
startWorkout(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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startWorkout(with split: Split) {
|
|
let start = 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: start
|
|
)
|
|
}
|
|
|
|
// 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: start,
|
|
end: nil,
|
|
status: WorkoutStatus.notStarted.rawValue,
|
|
createdAt: start,
|
|
updatedAt: start,
|
|
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.
|
|
services.workoutLauncher.launchWatchWorkout()
|
|
dismiss()
|
|
}
|
|
}
|