Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// RoutinePickerSheet.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Routine Picker Sheet
|
||||
|
||||
struct RoutinePickerSheet: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Called with the new workout's id right after it's saved, so the presenting
|
||||
/// screen can navigate into it once the cache catches up.
|
||||
var onStarted: (String) -> Void = { _ in }
|
||||
|
||||
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||
private var routines: [Routine]
|
||||
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
|
||||
/// Set when the user picks a routine while other workouts are still going — drives the
|
||||
/// "end the current one(s) or run in parallel?" prompt.
|
||||
@State private var routineAwaitingConfirmation: Routine?
|
||||
|
||||
private var activeWorkouts: [Workout] {
|
||||
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||
}
|
||||
|
||||
/// The routines most recently trained, newest first, each with the start date
|
||||
/// of its latest workout — derived from the workout log (`workouts` is
|
||||
/// already sorted by start descending). Each workout's `routineID` follows the
|
||||
/// seed clone-on-edit redirect so a workout started from a since-forked seed
|
||||
/// still credits the live clone. Deduped, capped, and limited to routines that
|
||||
/// still exist.
|
||||
private var recentRoutines: [(routine: Routine, lastStart: Date)] {
|
||||
var seen = Set<String>()
|
||||
var recents: [(routine: Routine, lastStart: Date)] = []
|
||||
for workout in workouts {
|
||||
guard let routineID = workout.routineID else { continue }
|
||||
let liveID = sync.currentRoutineID(for: routineID)
|
||||
guard !seen.contains(liveID) else { continue }
|
||||
seen.insert(liveID)
|
||||
if let routine = routines.first(where: { $0.id == liveID }) {
|
||||
recents.append((routine, workout.start))
|
||||
if recents.count == 3 { break }
|
||||
}
|
||||
}
|
||||
return recents
|
||||
}
|
||||
|
||||
/// One selectable routine row, shared by the Recent and All Routines sections.
|
||||
/// Recent rows pass `lastTrained` to show how long ago the routine was run.
|
||||
private func routineRow(_ routine: Routine, lastTrained: Date? = nil) -> some View {
|
||||
Button {
|
||||
confirmAndStart(with: routine)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: routine.systemImage)
|
||||
.foregroundColor(Color.color(from: routine.color))
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(routine.name)
|
||||
if let lastTrained {
|
||||
Text(lastTrained.daysAgoLabel())
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(routine.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if !recentRoutines.isEmpty {
|
||||
Section("Recent") {
|
||||
ForEach(recentRoutines, id: \.routine.id) { recent in
|
||||
routineRow(recent.routine, lastTrained: recent.lastStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(recentRoutines.isEmpty ? "" : "All Routines") {
|
||||
ForEach(routines) { routine in
|
||||
routineRow(routine)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select a Routine")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
activePromptTitle,
|
||||
isPresented: Binding(
|
||||
get: { routineAwaitingConfirmation != nil },
|
||||
set: { if !$0 { routineAwaitingConfirmation = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: routineAwaitingConfirmation
|
||||
) { routine in
|
||||
Button("End Current & Start New") { endActiveThenStart(with: routine) }
|
||||
Button("Start in Parallel") { start(with: routine) }
|
||||
Button("Cancel", role: .cancel) { routineAwaitingConfirmation = 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 routine: Routine) {
|
||||
if activeWorkouts.isEmpty {
|
||||
start(with: routine)
|
||||
} else {
|
||||
routineAwaitingConfirmation = routine
|
||||
}
|
||||
}
|
||||
|
||||
/// End every in-flight workout (keeping its progress), then start the picked routine.
|
||||
private func endActiveThenStart(with routine: Routine) {
|
||||
// A pristine draft (no exercise ever started) has nothing worth keeping —
|
||||
// "ending" it would mint a junk all-skipped completed workout, so it's
|
||||
// discarded instead, same rule as backing out of one.
|
||||
let toDiscard = activeWorkouts.filter(\.isPristineDraft)
|
||||
let toEnd = activeWorkouts.filter { !$0.isPristineDraft }.map { WorkoutDocument(from: $0) }
|
||||
routineAwaitingConfirmation = nil
|
||||
Task {
|
||||
for workout in toDiscard {
|
||||
await sync.delete(workout: workout)
|
||||
}
|
||||
for var doc in toEnd {
|
||||
doc.endKeepingProgress()
|
||||
await sync.save(workout: doc)
|
||||
}
|
||||
}
|
||||
start(with: routine)
|
||||
}
|
||||
|
||||
private func start(with routine: Routine) {
|
||||
// Hand the id back after the save so the presenter can poll the cache and
|
||||
// navigate into the run — mirroring the exercise-list start path.
|
||||
Task {
|
||||
let id = await WorkoutStarter.start(routine: routine, sync: sync)
|
||||
onStarted(id)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// StartedWorkoutNavigator.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Started-Workout Navigation
|
||||
|
||||
/// Drives a programmatic push into a freshly-started workout's log screen. Both start
|
||||
/// paths — the routine picker sheet and the Today board — mint a `WorkoutDocument`,
|
||||
/// write it, then hand its id here; the workout row only materializes once the
|
||||
/// file→observer→cache loop round-trips, so we poll the cache for the entity and push it
|
||||
/// the moment it lands.
|
||||
private struct StartedWorkoutNavigator: ViewModifier {
|
||||
/// The pushed state is a plain value wrapping the run's id — never a retained
|
||||
/// `@Model`. This screen stays pushed for the whole workout, and the run's entity
|
||||
/// can be deleted and re-imported underneath it (observer remove/re-add churn, a
|
||||
/// remote delete); a `@Model` held in `@State` would be *invalidated* by then —
|
||||
/// deleted-and-saved models read `isDeleted == false` yet trap on any property
|
||||
/// access (the TestFlight 2.3 (125) "crashed when watch ended an exercise" report).
|
||||
private struct RunRoute: Identifiable, Hashable { let id: String }
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Binding var pendingWorkoutID: String?
|
||||
@State private var resolvedRun: RunRoute?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.navigationDestination(item: $resolvedRun) { route in
|
||||
// Re-fetch on every build so the destination always maps a live
|
||||
// entity; a re-imported run resolves to its fresh instance.
|
||||
if let workout = CacheMapper.fetchWorkout(id: route.id, in: modelContext) {
|
||||
WorkoutLogListView(workout: workout)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Workout Unavailable",
|
||||
systemImage: "xmark.circle",
|
||||
description: Text("This workout is no longer on this device."))
|
||||
}
|
||||
}
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
// Backing out of a run that never started an exercise discards it —
|
||||
// tapping into a routine to look around must not persist a phantom
|
||||
// workout. Anything with real progress is kept.
|
||||
.onChange(of: resolvedRun) { old, new in
|
||||
guard let old, new == nil else { return }
|
||||
discardIfPristine(id: old.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the just-popped workout if it's still an untouched draft. Fetches
|
||||
/// fresh by id — never a retained entity — and re-checks liveness.
|
||||
private func discardIfPristine(id: String) {
|
||||
guard let workout = CacheMapper.fetchWorkout(id: id, in: modelContext),
|
||||
!workout.isDeleted, workout.isPristineDraft else { return }
|
||||
Task { await sync.delete(workout: workout) }
|
||||
}
|
||||
|
||||
/// Poll for the entity after we write the document (the file→observer→cache loop
|
||||
/// typically completes in well under a second). Clear the pending id once it
|
||||
/// resolves, or silently after ~3 s if it never arrives.
|
||||
private func pollForWorkout(id: String) {
|
||||
Task {
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
if CacheMapper.fetchWorkout(id: id, in: modelContext) != nil {
|
||||
resolvedRun = RunRoute(id: id)
|
||||
pendingWorkoutID = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
pendingWorkoutID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Navigate into a workout's log screen once it appears in the cache after being
|
||||
/// started. Bind to the state you set to the new workout's id right after saving it.
|
||||
func navigatesToStartedWorkout(pendingWorkoutID: Binding<String?>) -> some View {
|
||||
modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// WorkoutStarter.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import Foundation
|
||||
|
||||
/// The one place a workout is created from a routine. Both start paths — the routine
|
||||
/// picker sheet and the Today board — call this so the plan-time log snapshot and
|
||||
/// the returned id stay identical per site.
|
||||
@MainActor
|
||||
enum WorkoutStarter {
|
||||
/// Builds and saves a fresh workout from a routine (plan-time log snapshot per
|
||||
/// exercise) and returns the new workout's id so the caller can navigate once the
|
||||
/// cache catches up. Deliberately does NOT touch the watch: the wrist session
|
||||
/// launches only when the first exercise actually starts (see
|
||||
/// `SyncEngine.onWorkoutBecameActive`), so a peek-then-back never spins one up.
|
||||
static func start(routine: Routine, sync: SyncEngine) async -> String {
|
||||
let startDate = Date()
|
||||
let logs = routine.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(),
|
||||
routineID: routine.id,
|
||||
routineName: routine.name,
|
||||
start: startDate,
|
||||
end: nil,
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
createdAt: startDate,
|
||||
updatedAt: startDate,
|
||||
logs: logs,
|
||||
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance
|
||||
)
|
||||
|
||||
await sync.save(workout: doc)
|
||||
return doc.id
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ struct ExerciseAddEditView: View {
|
||||
|
||||
// The exercise entity provides initial values (read-only).
|
||||
let exercise: Exercise
|
||||
// The parent split is needed to rebuild and save the SplitDocument.
|
||||
let split: Split
|
||||
// The parent routine is needed to rebuild and save the RoutineDocument.
|
||||
let routine: Routine
|
||||
|
||||
// Local editable state
|
||||
@State private var exerciseName: String
|
||||
@@ -37,9 +37,9 @@ struct ExerciseAddEditView: View {
|
||||
@State private var machineSettings: [MachineSetting]
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(exercise: Exercise, split: Split) {
|
||||
init(exercise: Exercise, routine: Routine) {
|
||||
self.exercise = exercise
|
||||
self.split = split
|
||||
self.routine = routine
|
||||
|
||||
// The tens/ones pickers step whole values; a fractional stored weight
|
||||
// shows (and saves back) its whole part until UX #3's UI lands.
|
||||
@@ -197,7 +197,7 @@ struct ExerciseAddEditView: View {
|
||||
let newWeight = weightTens + weightOnes
|
||||
let durationSecs = minutes * 60 + seconds
|
||||
|
||||
var doc = SplitDocument(from: split)
|
||||
var doc = RoutineDocument(from: routine)
|
||||
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) {
|
||||
doc.exercises[idx].name = exerciseName
|
||||
doc.exercises[idx].sets = sets
|
||||
@@ -209,6 +209,6 @@ struct ExerciseAddEditView: View {
|
||||
doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@ struct ExerciseLibraryDetailView: View {
|
||||
|
||||
let exerciseName: String
|
||||
|
||||
/// This exercise wherever it appears in the user's splits — the source of the
|
||||
/// This exercise wherever it appears in the user's routines — the source of the
|
||||
/// recorded machine settings.
|
||||
@Query private var exercises: [Exercise]
|
||||
|
||||
/// Presents the settings editor; carries the splits the edit writes back to.
|
||||
/// Presents the settings editor; carries the routines the edit writes back to.
|
||||
@State private var settingsEdit: SettingsEditRoute?
|
||||
|
||||
/// True while content extends below the scroll area — drives the bottom fade
|
||||
@@ -63,31 +63,31 @@ struct ExerciseLibraryDetailView: View {
|
||||
}
|
||||
|
||||
/// Distinct recorded machine-settings lists for this exercise. Usually one; when
|
||||
/// splits disagree, each distinct list keeps its split's name as a label. Each
|
||||
/// group carries every split showing that list, so an edit updates them all and
|
||||
/// routines disagree, each distinct list keeps its routine's name as a label. Each
|
||||
/// group carries every routine showing that list, so an edit updates them all and
|
||||
/// they stay deduped.
|
||||
private var settingsGroups: [SettingsGroup] {
|
||||
var groups: [SettingsGroup] = []
|
||||
for exercise in exercises {
|
||||
guard let settings = exercise.machineSettings, !settings.isEmpty else { continue }
|
||||
if let i = groups.firstIndex(where: { $0.settings == settings }) {
|
||||
if let id = exercise.split?.id { groups[i].splitIDs.append(id) }
|
||||
if let id = exercise.routine?.id { groups[i].routineIDs.append(id) }
|
||||
continue
|
||||
}
|
||||
groups.append(SettingsGroup(
|
||||
label: exercise.split?.name,
|
||||
label: exercise.routine?.name,
|
||||
settings: settings,
|
||||
splitIDs: exercise.split.map { [$0.id] } ?? []
|
||||
routineIDs: exercise.routine.map { [$0.id] } ?? []
|
||||
))
|
||||
}
|
||||
if groups.count == 1 { groups[0].label = nil }
|
||||
return groups
|
||||
}
|
||||
|
||||
/// Every split containing this exercise — the write targets when settings are
|
||||
/// Every routine containing this exercise — the write targets when settings are
|
||||
/// recorded here for the first time.
|
||||
private var containingSplitIDs: [String] {
|
||||
exercises.compactMap { $0.split?.id }
|
||||
private var containingRoutineIDs: [String] {
|
||||
exercises.compactMap { $0.routine?.id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -103,10 +103,10 @@ struct ExerciseLibraryDetailView: View {
|
||||
MachineSettingsCard(
|
||||
groups: [],
|
||||
onEdit: edit(group:),
|
||||
// Recording needs a split exercise to write to — without
|
||||
// Recording needs a routine exercise to write to — without
|
||||
// one the card explains instead of offering a dead button.
|
||||
onAdd: containingSplitIDs.isEmpty ? nil : {
|
||||
settingsEdit = SettingsEditRoute(initial: [], targetSplitIDs: containingSplitIDs)
|
||||
onAdd: containingRoutineIDs.isEmpty ? nil : {
|
||||
settingsEdit = SettingsEditRoute(initial: [], targetRoutineIDs: containingRoutineIDs)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -165,10 +165,10 @@ struct ExerciseLibraryDetailView: View {
|
||||
.onDisappear { services.speechAnnouncer.stop() }
|
||||
.sheet(item: $settingsEdit) { route in
|
||||
MachineSettingsSheet(exerciseName: exerciseName, initialSettings: route.initial) { updated in
|
||||
let targets = route.targetSplitIDs
|
||||
let targets = route.targetRoutineIDs
|
||||
Task {
|
||||
for splitID in targets {
|
||||
await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, splitID: splitID)
|
||||
for routineID in targets {
|
||||
await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, routineID: routineID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,29 +176,29 @@ struct ExerciseLibraryDetailView: View {
|
||||
}
|
||||
|
||||
private func edit(group: SettingsGroup) {
|
||||
settingsEdit = SettingsEditRoute(initial: group.settings, targetSplitIDs: group.splitIDs)
|
||||
settingsEdit = SettingsEditRoute(initial: group.settings, targetRoutineIDs: group.routineIDs)
|
||||
}
|
||||
}
|
||||
|
||||
/// One distinct recorded settings list and the splits it came from (the edit targets).
|
||||
/// One distinct recorded settings list and the routines it came from (the edit targets).
|
||||
fileprivate struct SettingsGroup {
|
||||
var label: String?
|
||||
var settings: [MachineSetting]
|
||||
var splitIDs: [String]
|
||||
var routineIDs: [String]
|
||||
}
|
||||
|
||||
/// Sheet route: the settings to seed the editor with and the splits a save writes to.
|
||||
/// Sheet route: the settings to seed the editor with and the routines a save writes to.
|
||||
private struct SettingsEditRoute: Identifiable {
|
||||
let id = UUID()
|
||||
var initial: [MachineSetting]
|
||||
var targetSplitIDs: [String]
|
||||
var targetRoutineIDs: [String]
|
||||
}
|
||||
|
||||
/// The user's recorded machine comfort settings, set apart from the reference text
|
||||
/// as a card: name–value rows, grouped per split when the recorded lists differ.
|
||||
/// as a card: name–value rows, grouped per routine when the recorded lists differ.
|
||||
/// Empty `groups` renders the not-yet-recorded state (shown for machine-based
|
||||
/// exercises so the feature is visible before first use), with an Add button when
|
||||
/// the exercise lives in at least one split (`onAdd` non-nil). A single group edits
|
||||
/// the exercise lives in at least one routine (`onAdd` non-nil). A single group edits
|
||||
/// from the header; disagreeing groups edit per group.
|
||||
private struct MachineSettingsCard: View {
|
||||
let groups: [SettingsGroup]
|
||||
@@ -222,7 +222,7 @@ private struct MachineSettingsCard: View {
|
||||
if groups.isEmpty {
|
||||
Text(onAdd != nil
|
||||
? "None recorded yet — save the seat, pad, and pin positions once and they'll be here next time."
|
||||
: "None recorded yet. Add this exercise to a split, then record its settings here or from the workout screen.")
|
||||
: "None recorded yet. Add this exercise to a routine, then record its settings here or from the workout screen.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -15,41 +15,41 @@ struct ExerciseListView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Resolve the split by id, not a captured entity: editing a seed's exercise from
|
||||
// Resolve the routine 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]
|
||||
// stored `Routine`. `currentRoutineID` follows that swap.
|
||||
@State private var routineID: String
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
@State private var showingAddSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(split: Split) {
|
||||
_splitID = State(initialValue: split.id)
|
||||
init(routine: Routine) {
|
||||
_routineID = State(initialValue: routine.id)
|
||||
}
|
||||
|
||||
private var split: Split? {
|
||||
let id = sync.currentSplitID(for: splitID)
|
||||
return splits.first { $0.id == id }
|
||||
private var routine: Routine? {
|
||||
let id = sync.currentRoutineID(for: routineID)
|
||||
return routines.first { $0.id == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let split {
|
||||
content(for: split)
|
||||
if let routine {
|
||||
content(for: routine)
|
||||
} else {
|
||||
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
|
||||
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
|
||||
.task { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for split: Split) -> some View {
|
||||
private func content(for routine: Routine) -> some View {
|
||||
Form {
|
||||
let sortedExercises = split.exercisesArray
|
||||
let sortedExercises = routine.exercisesArray
|
||||
|
||||
if !sortedExercises.isEmpty {
|
||||
ForEach(sortedExercises) { item in
|
||||
@@ -86,14 +86,14 @@ struct ExerciseListView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(split.name)
|
||||
.navigationTitle(routine.name)
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
addExercises(names: exerciseNames)
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item, split: split)
|
||||
ExerciseAddEditView(exercise: item, routine: routine)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
@@ -112,29 +112,29 @@ struct ExerciseListView: View {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: { item in
|
||||
Text("Remove \"\(item.name)\" from this split?")
|
||||
Text("Remove \"\(item.name)\" from this routine?")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
guard let split else { return }
|
||||
var exercises = split.exercisesArray
|
||||
guard let routine else { return }
|
||||
var exercises = routine.exercisesArray
|
||||
exercises.move(fromOffsets: source, toOffset: destination)
|
||||
var doc = SplitDocument(from: split)
|
||||
var doc = RoutineDocument(from: routine)
|
||||
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) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
guard let split else { return }
|
||||
var doc = SplitDocument(from: split)
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
let existingNames = Set(doc.exercises.map { $0.name })
|
||||
let base = doc.exercises.count
|
||||
let newDocs = names
|
||||
@@ -154,17 +154,17 @@ struct ExerciseListView: View {
|
||||
}
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
|
||||
private func deleteExercise(_ exercise: Exercise) {
|
||||
guard let split else { return }
|
||||
var doc = SplitDocument(from: split)
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
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) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// AchievementsSection.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The achievements badge grid. Every badge is a pure derivation of history
|
||||
/// (`ProgressPlanner.achievements`) — earned ones glow in their color, locked ones
|
||||
/// wear a progress ring showing how close they are. Tapping shows the how-to-earn
|
||||
/// detail.
|
||||
struct AchievementsSection: View {
|
||||
let achievements: [Achievement]
|
||||
|
||||
@State private var selected: Achievement?
|
||||
|
||||
private var earnedCount: Int { achievements.filter(\.earned).count }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Text("Achievements")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text("\(earnedCount) of \(achievements.count)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4),
|
||||
spacing: 16) {
|
||||
ForEach(achievements) { achievement in
|
||||
Button {
|
||||
selected = achievement
|
||||
} label: {
|
||||
AchievementBadge(achievement: achievement)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.progressCard()
|
||||
.alert(
|
||||
selected?.title ?? "",
|
||||
isPresented: Binding(
|
||||
get: { selected != nil },
|
||||
set: { if !$0 { selected = nil } }
|
||||
),
|
||||
presenting: selected
|
||||
) { _ in
|
||||
Button("OK") { selected = nil }
|
||||
} message: { achievement in
|
||||
Text(achievement.earned
|
||||
? achievement.detail
|
||||
: "\(achievement.detail) — \(Int((achievement.progress * 100).rounded()))% there.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One badge: a tinted circle with the symbol, the title beneath. Locked badges are
|
||||
/// desaturated with a thin progress arc around the circle.
|
||||
struct AchievementBadge: View {
|
||||
let achievement: Achievement
|
||||
|
||||
private var color: Color { Color.color(from: achievement.colorName) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(achievement.earned ? color.opacity(0.18) : Color(.systemFill))
|
||||
Image(systemName: achievement.systemImage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(achievement.earned ? color : Color(.tertiaryLabel))
|
||||
}
|
||||
.frame(width: 52, height: 52)
|
||||
.overlay {
|
||||
if !achievement.earned, achievement.progress > 0 {
|
||||
Circle()
|
||||
.trim(from: 0, to: achievement.progress)
|
||||
.stroke(color.opacity(0.6),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
}
|
||||
|
||||
Text(achievement.title)
|
||||
.font(.caption2)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2, reservesSpace: true)
|
||||
.foregroundStyle(achievement.earned ? .primary : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(
|
||||
"\(achievement.title), \(achievement.earned ? "earned" : "locked"): \(achievement.detail)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//
|
||||
// ExerciseTrendsView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Per-exercise drill-down: every weighted exercise with completed history, most
|
||||
/// recently trained first, each opening its weight-progression detail. This promotes
|
||||
/// the progression chart to a first-class Progress surface (it also remains on the
|
||||
/// library's reference pages).
|
||||
struct ExerciseTrendsListView: View {
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
/// Completed weighted logs, newest first.
|
||||
@Query private var logs: [WorkoutLog]
|
||||
|
||||
init() {
|
||||
let completedRaw = WorkoutStatus.completed.rawValue
|
||||
let weightRaw = LoadType.weight.rawValue
|
||||
_logs = Query(
|
||||
filter: #Predicate<WorkoutLog> {
|
||||
$0.statusRaw == completedRaw && $0.loadType == weightRaw
|
||||
},
|
||||
sort: \WorkoutLog.date,
|
||||
order: .reverse)
|
||||
}
|
||||
|
||||
private struct ExerciseSummary: Identifiable {
|
||||
var name: String
|
||||
var sessions: Int
|
||||
var best: Double
|
||||
var last: Date
|
||||
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
/// One row per exercise name: session count, best top-set weight, last trained.
|
||||
/// `logs` is newest-first, so the first log seen per name fixes `last`.
|
||||
private var summaries: [ExerciseSummary] {
|
||||
var byName: [String: ExerciseSummary] = [:]
|
||||
for log in logs {
|
||||
let top = log.setEntries?.compactMap(\.weight).max() ?? log.weight
|
||||
if var summary = byName[log.exerciseName] {
|
||||
summary.sessions += 1
|
||||
summary.best = max(summary.best, top)
|
||||
byName[log.exerciseName] = summary
|
||||
} else {
|
||||
byName[log.exerciseName] = ExerciseSummary(
|
||||
name: log.exerciseName, sessions: 1, best: top, last: log.date)
|
||||
}
|
||||
}
|
||||
return byName.values.sorted { $0.last > $1.last }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List(summaries) { summary in
|
||||
NavigationLink {
|
||||
ExerciseTrendDetailView(exerciseName: summary.name)
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(summary.name)
|
||||
Text("\(summary.sessions) session\(summary.sessions == 1 ? "" : "s") · last \(summary.last.daysAgoLabel())")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(weightUnit.format(summary.best))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if summaries.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Weighted Exercises Yet",
|
||||
systemImage: "dumbbell",
|
||||
description: Text("Complete weighted sets and each exercise's progression shows up here."))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Exercise Trends")
|
||||
}
|
||||
}
|
||||
|
||||
/// One exercise's progression: headline stats over its completed history plus the
|
||||
/// weight-progression chart.
|
||||
struct ExerciseTrendDetailView: View {
|
||||
let exerciseName: String
|
||||
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
/// Completed logs for this exercise, oldest first (matching the chart's order).
|
||||
@Query private var logs: [WorkoutLog]
|
||||
|
||||
init(exerciseName: String) {
|
||||
self.exerciseName = exerciseName
|
||||
let name = exerciseName
|
||||
let completedRaw = WorkoutStatus.completed.rawValue
|
||||
_logs = Query(
|
||||
filter: #Predicate<WorkoutLog> {
|
||||
$0.exerciseName == name && $0.statusRaw == completedRaw
|
||||
},
|
||||
sort: \WorkoutLog.date,
|
||||
order: .forward)
|
||||
}
|
||||
|
||||
private var bestWeight: Double {
|
||||
logs.map { $0.setEntries?.compactMap(\.weight).max() ?? $0.weight }.max() ?? 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 12) {
|
||||
ProgressStatTile(
|
||||
title: "Best",
|
||||
value: weightUnit.format(bestWeight),
|
||||
unit: nil)
|
||||
ProgressStatTile(
|
||||
title: "Sessions",
|
||||
value: "\(logs.count)",
|
||||
unit: nil)
|
||||
ProgressStatTile(
|
||||
title: "Last",
|
||||
value: logs.last?.date.daysAgoLabel() ?? "—",
|
||||
unit: nil)
|
||||
}
|
||||
|
||||
WeightProgressionChartView(exerciseName: exerciseName)
|
||||
.progressCard()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle(exerciseName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//
|
||||
// GoalTrackCard.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// One goal's adherence card: a 12-week strip (each square filled by that week's
|
||||
/// met/expected fraction), the weekly streak flame, and a this-week dot row per
|
||||
/// member schedule. The goal is the ledger category — this track continues unbroken
|
||||
/// across schedule rewrites.
|
||||
struct GoalTrackCard: View {
|
||||
let track: GoalTrack
|
||||
|
||||
private var color: Color {
|
||||
track.goal.map { Color.color(from: $0.colorName) } ?? Color(.systemGray)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label(track.goal?.displayName ?? "Unassigned",
|
||||
systemImage: track.goal?.systemImage ?? "tag")
|
||||
.font(.headline)
|
||||
.foregroundStyle(color)
|
||||
Spacer()
|
||||
if track.streakWeeks > 0 {
|
||||
streakBadge
|
||||
}
|
||||
}
|
||||
|
||||
weekStrip
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(track.scheduleTracks, id: \.schedule.id) { scheduleTrack in
|
||||
scheduleRow(scheduleTrack)
|
||||
}
|
||||
}
|
||||
}
|
||||
.progressCard()
|
||||
}
|
||||
|
||||
/// The streak flame: orange while the current week is still meetable, dimmed the
|
||||
/// moment it can no longer be (the streak is at stake, not yet erased).
|
||||
private var streakBadge: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "flame.fill")
|
||||
Text("\(track.streakWeeks) wk")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(track.streakAlive ? Color.orange : Color.secondary)
|
||||
.accessibilityLabel("\(track.streakWeeks)-week streak\(track.streakAlive ? "" : ", at risk")")
|
||||
}
|
||||
|
||||
private var weekStrip: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(track.weeks.enumerated()), id: \.offset) { index, week in
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(fill(for: week))
|
||||
.frame(height: 16)
|
||||
.overlay {
|
||||
// Outline the in-flight week so "unfilled" reads as "not over
|
||||
// yet" rather than "missed".
|
||||
if index == track.weeks.count - 1 {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.strokeBorder(color.opacity(0.8), lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(stripSummary)
|
||||
}
|
||||
|
||||
private func fill(for week: WeekMark) -> Color {
|
||||
if week.isVacuous { return Color(.systemFill).opacity(0.4) }
|
||||
if week.fraction == 0 { return Color(.systemFill) }
|
||||
return color.opacity(0.25 + 0.75 * week.fraction)
|
||||
}
|
||||
|
||||
private var stripSummary: String {
|
||||
let hit = track.weeks.filter { !$0.isVacuous && $0.hit }.count
|
||||
let active = track.weeks.filter { !$0.isVacuous }.count
|
||||
return "Hit \(hit) of the last \(active) weeks"
|
||||
}
|
||||
|
||||
private func scheduleRow(_ scheduleTrack: ScheduleTrack) -> some View {
|
||||
let schedule = scheduleTrack.schedule
|
||||
return HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(schedule.routineName)
|
||||
.font(.subheadline)
|
||||
Text(schedule.recurrence.summary(
|
||||
weekdays: schedule.weekdays,
|
||||
timesPerWeek: schedule.timesPerWeek,
|
||||
date: schedule.onceDate))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
thisWeekDots(scheduleTrack)
|
||||
}
|
||||
}
|
||||
|
||||
/// This week for one schedule: `expected` dots, `met` of them filled. A broken
|
||||
/// week adds a quiet "missed" tag — informative, not scolding.
|
||||
@ViewBuilder
|
||||
private func thisWeekDots(_ scheduleTrack: ScheduleTrack) -> some View {
|
||||
if let week = scheduleTrack.currentWeek, !week.isVacuous {
|
||||
HStack(spacing: 5) {
|
||||
if scheduleTrack.currentWeekBroken {
|
||||
Text("missed")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(0..<week.expected, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index < week.met ? color : Color.clear)
|
||||
.overlay {
|
||||
if index >= week.met {
|
||||
Circle().strokeBorder(color.opacity(0.4), lineWidth: 1.5)
|
||||
}
|
||||
}
|
||||
.frame(width: 9, height: 9)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("\(week.met) of \(week.expected) this week")
|
||||
} else {
|
||||
Text("—")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
//
|
||||
// ProgressTabView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The Progress tab — observe by goals (UX-REDESIGN.md). Per-goal adherence tracks
|
||||
/// first, then this-week totals, aggregate trends, per-exercise drill-down, the
|
||||
/// achievements grid, and full history (absorbed from the old Log tab). Everything on
|
||||
/// this screen is derived by `ProgressPlanner` from the workout documents; nothing is
|
||||
/// persisted, so it all survives cache rebuilds.
|
||||
struct ProgressTabView: View {
|
||||
/// Screenshot-capture affordance: `.bottom` opens the tab pre-scrolled to the
|
||||
/// end (nil = the normal top). Only the DEBUG screenshot root passes it.
|
||||
var initialAnchor: UnitPoint?
|
||||
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
|
||||
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
|
||||
private var schedules: [Schedule]
|
||||
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
let facts = workoutFacts
|
||||
let tracks = ProgressPlanner.goalTracks(
|
||||
schedules: scheduleFacts, workouts: facts,
|
||||
goalOrder: GoalKind.orderedCases())
|
||||
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
WeekSummaryTiles(workouts: facts)
|
||||
|
||||
if tracks.isEmpty {
|
||||
goalTracksHint
|
||||
} else {
|
||||
ForEach(tracks) { GoalTrackCard(track: $0) }
|
||||
}
|
||||
|
||||
TrendsSection(workouts: facts)
|
||||
|
||||
exerciseTrendsLink
|
||||
|
||||
AchievementsSection(
|
||||
achievements: ProgressPlanner.achievements(tracks: tracks, workouts: facts))
|
||||
|
||||
historySection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.defaultScrollAnchor(initialAnchor)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Progress")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fact building (live-resolve IDs through the seed clone-on-edit redirect)
|
||||
|
||||
private var scheduleFacts: [ScheduleFacts] {
|
||||
schedules.map { schedule in
|
||||
ScheduleFacts(
|
||||
id: schedule.id,
|
||||
goal: schedule.goalKind,
|
||||
recurrence: schedule.recurrenceEnum,
|
||||
weekdays: schedule.weekdays,
|
||||
timesPerWeek: schedule.timesPerWeek,
|
||||
onceDate: schedule.date,
|
||||
createdAt: schedule.createdAt,
|
||||
routineID: sync.currentRoutineID(for: schedule.routineID),
|
||||
routineName: schedule.routineName)
|
||||
}
|
||||
}
|
||||
|
||||
private var workoutFacts: [WorkoutFacts] {
|
||||
workouts.map { workout in
|
||||
WorkoutFacts(
|
||||
routineID: workout.routineID.map { sync.currentRoutineID(for: $0) },
|
||||
start: workout.start,
|
||||
end: workout.end,
|
||||
completed: workout.status == .completed,
|
||||
volume: Self.volume(of: workout),
|
||||
activeMinutes: workout.end.map { max(0, $0.timeIntervalSince(workout.start) / 60) } ?? 0,
|
||||
energyKcal: workout.metricActiveEnergyKcal ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Total lifted volume for one workout: the recorded watch metric when present,
|
||||
/// else derived from per-set actuals, else the plan (the same fallback ladder the
|
||||
/// documents' `effectiveSetEntries` uses).
|
||||
static func volume(of workout: Workout) -> Double {
|
||||
if let recorded = workout.metricTotalVolume { return recorded }
|
||||
return workout.logs.reduce(0) { sum, log in
|
||||
guard log.status == .completed, log.loadTypeEnum == .weight else { return sum }
|
||||
if let entries = log.setEntries, !entries.isEmpty {
|
||||
return sum + entries.reduce(0) { $0 + Double($1.reps ?? 0) * ($1.weight ?? 0) }
|
||||
}
|
||||
return sum + Double(log.sets * log.reps) * log.weight
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var goalTracksHint: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("No Goal Tracks Yet", systemImage: "chart.bar.fill")
|
||||
.font(.headline)
|
||||
Text("Add schedules on the Today tab and each goal builds a weekly adherence track here.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.progressCard()
|
||||
}
|
||||
|
||||
private var exerciseTrendsLink: some View {
|
||||
NavigationLink {
|
||||
ExerciseTrendsListView()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 32)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Exercise Trends")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Text("Weight progression for every exercise you've logged")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.progressCard()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var historySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("History")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
NavigationLink("All Workouts") {
|
||||
WorkoutHistoryView()
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
if workouts.isEmpty {
|
||||
Text("No workouts yet. Start one from the Today tab.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(workouts.prefix(3)) { workout in
|
||||
NavigationLink {
|
||||
WorkoutLogListView(workout: workout)
|
||||
} label: {
|
||||
HStack {
|
||||
CalendarListItem(
|
||||
date: workout.start,
|
||||
title: workout.routineName ?? Routine.unnamed,
|
||||
subtitle: subtitle(for: workout),
|
||||
subtitle2: workout.statusName)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.progressCard()
|
||||
}
|
||||
|
||||
private func subtitle(for workout: Workout) -> String {
|
||||
if workout.status == .completed, let endDate = workout.end {
|
||||
return workout.start.humanTimeInterval(to: endDate)
|
||||
}
|
||||
return workout.start.formattedDate()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared card chrome
|
||||
|
||||
extension View {
|
||||
/// The Progress tab's card treatment — one shared background so every section
|
||||
/// reads as part of the same system.
|
||||
func progressCard() -> some View {
|
||||
padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - This-week stat tiles
|
||||
|
||||
/// Three at-a-glance totals for the current calendar week, each with a delta arrow
|
||||
/// against last week.
|
||||
struct WeekSummaryTiles: View {
|
||||
let workouts: [WorkoutFacts]
|
||||
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
var body: some View {
|
||||
let calendar = Calendar.current
|
||||
let now = Date()
|
||||
let this = totals(in: calendar.dateInterval(of: .weekOfYear, for: now))
|
||||
let last = totals(in: calendar.date(byAdding: .weekOfYear, value: -1, to: now)
|
||||
.flatMap { calendar.dateInterval(of: .weekOfYear, for: $0) })
|
||||
|
||||
HStack(spacing: 12) {
|
||||
ProgressStatTile(
|
||||
title: "This Week",
|
||||
value: "\(this.workouts)",
|
||||
unit: this.workouts == 1 ? "workout" : "workouts",
|
||||
delta: Double(this.workouts - last.workouts),
|
||||
deltaText: "\(abs(this.workouts - last.workouts))")
|
||||
ProgressStatTile(
|
||||
title: "Volume",
|
||||
value: this.volume.formatted(.number.notation(.compactName).precision(.significantDigits(3))),
|
||||
unit: weightUnit.abbreviation,
|
||||
delta: this.volume - last.volume,
|
||||
deltaText: abs(this.volume - last.volume)
|
||||
.formatted(.number.notation(.compactName).precision(.significantDigits(2))))
|
||||
ProgressStatTile(
|
||||
title: "Active Time",
|
||||
value: Self.hoursMinutes(this.minutes),
|
||||
unit: nil,
|
||||
delta: this.minutes - last.minutes,
|
||||
deltaText: Self.hoursMinutes(abs(this.minutes - last.minutes)))
|
||||
}
|
||||
}
|
||||
|
||||
private func totals(in interval: DateInterval?) -> (workouts: Int, volume: Double, minutes: Double) {
|
||||
guard let interval else { return (0, 0, 0) }
|
||||
let matching = workouts.filter { $0.completed && interval.contains($0.start) }
|
||||
return (matching.count,
|
||||
matching.reduce(0) { $0 + $1.volume },
|
||||
matching.reduce(0) { $0 + $1.activeMinutes })
|
||||
}
|
||||
|
||||
static func hoursMinutes(_ minutes: Double) -> String {
|
||||
let total = Int(minutes.rounded())
|
||||
let (h, m) = (total / 60, total % 60)
|
||||
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
|
||||
}
|
||||
}
|
||||
|
||||
/// One compact stat tile: caption, big value, and a vs-last-week arrow. Shared by the
|
||||
/// week summary row and the exercise drill-down.
|
||||
struct ProgressStatTile: View {
|
||||
let title: String
|
||||
let value: String
|
||||
var unit: String?
|
||||
/// Signed change vs the comparison period; only its sign is displayed.
|
||||
var delta: Double?
|
||||
/// Preformatted magnitude for the delta line (callers know their unit best).
|
||||
var deltaText: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||
Text(value)
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
if let unit {
|
||||
Text(unit)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let delta, delta != 0 {
|
||||
Label("\(deltaText ?? fallbackDeltaText(delta)) vs last wk",
|
||||
systemImage: delta > 0 ? "arrow.up" : "arrow.down")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(delta > 0 ? .green : .secondary)
|
||||
} else {
|
||||
// Keep tile heights equal whether or not a delta renders.
|
||||
Text("—")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
Color(.secondarySystemGroupedBackground),
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private func fallbackDeltaText(_ delta: Double) -> String {
|
||||
abs(delta).formatted(.number.notation(.compactName).precision(.significantDigits(2)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// TrendsSection.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
/// Aggregate trends over time: one chart, a metric picker (workouts, volume, active
|
||||
/// time, energy), and the standard 7D/30D/90D/1Y/All range picker. Counts render as
|
||||
/// bars (discrete events); the other metrics as the smooth line + points pattern.
|
||||
struct TrendsSection: View {
|
||||
let workouts: [WorkoutFacts]
|
||||
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
@State private var range: TrendRange = .month
|
||||
@State private var metric: Metric = .workouts
|
||||
|
||||
enum Metric: String, CaseIterable, Identifiable {
|
||||
case workouts = "Workouts"
|
||||
case volume = "Volume"
|
||||
case time = "Time"
|
||||
case energy = "Energy"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .workouts: .blue
|
||||
case .volume: .purple
|
||||
case .time: .green
|
||||
case .energy: .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Trends")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Metric", selection: $metric) {
|
||||
ForEach(Metric.allCases) { metric in
|
||||
Text(metric.rawValue).tag(metric)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Picker("Range", selection: $range) {
|
||||
ForEach(TrendRange.allCases) { range in
|
||||
Text(range.rawValue).tag(range)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
chart
|
||||
}
|
||||
.progressCard()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var chart: some View {
|
||||
let points = ProgressPlanner.trendPoints(workouts: workouts, range: range)
|
||||
if points.isEmpty || points.allSatisfy({ $0.workouts == 0 }) {
|
||||
ContentUnavailableView(
|
||||
"No Data",
|
||||
systemImage: "chart.xyaxis.line",
|
||||
description: Text(workouts.contains(where: \.completed)
|
||||
? "No workouts in this time range. Try a wider range."
|
||||
: "Complete workouts and your trends build up here."))
|
||||
.frame(height: 220)
|
||||
} else {
|
||||
Chart(points) { point in
|
||||
if metric == .workouts {
|
||||
BarMark(
|
||||
x: .value("Date", point.date, unit: range.bucket),
|
||||
y: .value(metric.rawValue, value(of: point))
|
||||
)
|
||||
.foregroundStyle(metric.color.gradient)
|
||||
.cornerRadius(3)
|
||||
} else {
|
||||
LineMark(
|
||||
x: .value("Date", point.date, unit: range.bucket),
|
||||
y: .value(metric.rawValue, value(of: point))
|
||||
)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.foregroundStyle(metric.color.gradient)
|
||||
|
||||
PointMark(
|
||||
x: .value("Date", point.date, unit: range.bucket),
|
||||
y: .value(metric.rawValue, value(of: point))
|
||||
)
|
||||
.symbolSize(30)
|
||||
.foregroundStyle(metric.color)
|
||||
}
|
||||
}
|
||||
.chartYAxisLabel(unitLabel)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic) { _ in
|
||||
AxisGridLine()
|
||||
AxisValueLabel(format: axisDateFormat)
|
||||
}
|
||||
}
|
||||
.frame(height: 220)
|
||||
}
|
||||
}
|
||||
|
||||
private func value(of point: TrendPoint) -> Double {
|
||||
switch metric {
|
||||
case .workouts: Double(point.workouts)
|
||||
case .volume: point.volume
|
||||
case .time: point.activeMinutes
|
||||
case .energy: point.energyKcal
|
||||
}
|
||||
}
|
||||
|
||||
private var unitLabel: String {
|
||||
switch metric {
|
||||
case .workouts: ""
|
||||
case .volume: weightUnit.abbreviation
|
||||
case .time: "min"
|
||||
case .energy: "kcal"
|
||||
}
|
||||
}
|
||||
|
||||
private var axisDateFormat: Date.FormatStyle {
|
||||
switch range.bucket {
|
||||
case .month: .dateTime.month(.abbreviated)
|
||||
default: .dateTime.month().day()
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-45
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// SplitAddEditView.swift
|
||||
// RoutineAddEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 9:42 AM.
|
||||
@@ -11,17 +11,17 @@ import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SplitAddEditView: View {
|
||||
struct RoutineAddEditView: 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: a clone-on-edit swaps a seed's
|
||||
// Resolve the routine by id, not a captured entity: a clone-on-edit swaps a seed's
|
||||
// identity mid-screen (the seed entity is deleted and a clone inserted), which
|
||||
// would dangle a stored `Split`. `currentSplitID` follows that swap. `splitID` is
|
||||
// would dangle a stored `Routine`. `currentRoutineID` follows that swap. `routineID` is
|
||||
// nil in create mode.
|
||||
@State private var splitID: String?
|
||||
@Query private var splits: [Split]
|
||||
@State private var routineID: String?
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
@@ -35,43 +35,43 @@ struct SplitAddEditView: View {
|
||||
@State private var showingIconPicker: Bool = false
|
||||
@State private var showingDeleteConfirmation: Bool = false
|
||||
|
||||
var isEditing: Bool { splitID != nil }
|
||||
var isEditing: Bool { routineID != nil }
|
||||
|
||||
init(split: Split?, onDelete: (() -> Void)? = nil) {
|
||||
_splitID = State(initialValue: split?.id)
|
||||
init(routine: Routine?, onDelete: (() -> Void)? = nil) {
|
||||
_routineID = State(initialValue: routine?.id)
|
||||
self.onDelete = onDelete
|
||||
if let split = split {
|
||||
_name = State(initialValue: split.name)
|
||||
_color = State(initialValue: split.color)
|
||||
_systemImage = State(initialValue: split.systemImage)
|
||||
_activityType = State(initialValue: split.activityTypeEnum)
|
||||
_autoAdvance = State(initialValue: split.autoAdvance ?? false)
|
||||
_restOverrideEnabled = State(initialValue: split.restSeconds != nil)
|
||||
_restSecondsValue = State(initialValue: split.restSeconds ?? 45)
|
||||
if let routine = routine {
|
||||
_name = State(initialValue: routine.name)
|
||||
_color = State(initialValue: routine.color)
|
||||
_systemImage = State(initialValue: routine.systemImage)
|
||||
_activityType = State(initialValue: routine.activityTypeEnum)
|
||||
_autoAdvance = State(initialValue: routine.autoAdvance ?? false)
|
||||
_restOverrideEnabled = State(initialValue: routine.restSeconds != nil)
|
||||
_restSecondsValue = State(initialValue: routine.restSeconds ?? 45)
|
||||
}
|
||||
}
|
||||
|
||||
private var split: Split? {
|
||||
guard let splitID else { return nil }
|
||||
let id = sync.currentSplitID(for: splitID)
|
||||
return splits.first { $0.id == id }
|
||||
private var routine: Routine? {
|
||||
guard let routineID else { return nil }
|
||||
let id = sync.currentRoutineID(for: routineID)
|
||||
return routines.first { $0.id == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
if isEditing && split == nil {
|
||||
// The id we held no longer maps to a live split (deleted on another
|
||||
if isEditing && routine == nil {
|
||||
// The id we held no longer maps to a live routine (deleted on another
|
||||
// device, or a transient mid-clone frame). Show nothing and leave.
|
||||
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
|
||||
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
|
||||
.task { dismiss() }
|
||||
} else {
|
||||
form(for: split)
|
||||
form(for: routine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func form(for split: Split?) -> some View {
|
||||
private func form(for routine: Routine?) -> some View {
|
||||
Form {
|
||||
Section(header: Text("Name")) {
|
||||
TextField("Name", text: $name)
|
||||
@@ -133,29 +133,29 @@ struct SplitAddEditView: View {
|
||||
} header: {
|
||||
Text("Rest & Pacing")
|
||||
} footer: {
|
||||
Text("Auto-Advance flows from one exercise to the next with a rest between, so you can run the whole split hands-free. Custom Rest Time sets this split's rest — used between sets, and between exercises when auto-advancing; otherwise the Settings default applies.")
|
||||
Text("Auto-Advance flows from one exercise to the next with a rest between, so you can run the whole routine hands-free. Custom Rest Time sets this routine's rest — used between sets, and between exercises when auto-advancing; otherwise the Settings default applies.")
|
||||
}
|
||||
|
||||
if let split = split {
|
||||
if let routine = routine {
|
||||
Section(header: Text("Exercises")) {
|
||||
NavigationLink {
|
||||
ExerciseListView(split: split)
|
||||
ExerciseListView(routine: routine)
|
||||
} label: {
|
||||
ListItem(
|
||||
text: "Exercises",
|
||||
count: split.exercisesArray.count
|
||||
count: routine.exercisesArray.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Delete Split", role: .destructive) {
|
||||
Button("Delete Routine", role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Split" : "New Split")
|
||||
.navigationTitle(isEditing ? "Edit Routine" : "New Routine")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
@@ -175,30 +175,30 @@ struct SplitAddEditView: View {
|
||||
SFSymbolPicker(selection: $systemImage)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete This Split?",
|
||||
"Delete This Routine?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let split = split {
|
||||
if let routine = routine {
|
||||
Task {
|
||||
await sync.delete(split: split)
|
||||
await sync.delete(routine: routine)
|
||||
}
|
||||
dismiss()
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete the split and all its exercises.")
|
||||
Text("This will permanently delete the routine and all its exercises.")
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if isEditing {
|
||||
// Update existing split. If the id no longer resolves (deleted remotely
|
||||
// Update existing routine. If the id no longer resolves (deleted remotely
|
||||
// mid-edit), there's nothing to save.
|
||||
guard let split = split else { return }
|
||||
var doc = SplitDocument(from: split)
|
||||
guard let routine = routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.name = name
|
||||
doc.color = color
|
||||
doc.systemImage = systemImage
|
||||
@@ -206,12 +206,12 @@ struct SplitAddEditView: View {
|
||||
doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil
|
||||
doc.autoAdvance = autoAdvance ? true : nil
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
} else {
|
||||
// Create new split
|
||||
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
|
||||
let doc = SplitDocument(
|
||||
schemaVersion: SplitDocument.currentSchemaVersion,
|
||||
// Create new routine
|
||||
let existing = (try? modelContext.fetch(FetchDescriptor<Routine>())) ?? []
|
||||
let doc = RoutineDocument(
|
||||
schemaVersion: RoutineDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
name: name,
|
||||
color: color,
|
||||
@@ -224,7 +224,7 @@ struct SplitAddEditView: View {
|
||||
restSeconds: restOverrideEnabled ? restSecondsValue : nil,
|
||||
autoAdvance: autoAdvance ? true : nil
|
||||
)
|
||||
Task { await sync.save(split: doc) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-46
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// SplitDetailView.swift
|
||||
// RoutineDetailView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/25/25 at 3:27 PM.
|
||||
@@ -11,70 +11,70 @@ import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SplitDetailView: View {
|
||||
struct RoutineDetailView: 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: a clone-on-edit swaps a seed's
|
||||
// Resolve the routine by id, not a captured entity: a clone-on-edit swaps a seed's
|
||||
// identity mid-screen (the seed entity is deleted and a clone inserted), which
|
||||
// would dangle a stored `Split`. `currentSplitID` follows that swap.
|
||||
@State private var splitID: String
|
||||
@Query private var splits: [Split]
|
||||
// would dangle a stored `Routine`. `currentRoutineID` follows that swap.
|
||||
@State private var routineID: String
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
@State private var showingExerciseAddSheet: Bool = false
|
||||
@State private var showingSplitEditSheet: Bool = false
|
||||
@State private var showingRoutineEditSheet: Bool = false
|
||||
@State private var itemToEdit: Exercise? = nil
|
||||
@State private var itemToDelete: Exercise? = nil
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
|
||||
init(split: Split) {
|
||||
init(routine: Routine) {
|
||||
// A closure-based `NavigationLink` builds this destination eagerly for every
|
||||
// row in the parent list, including during the update that fires when a split
|
||||
// row in the parent list, including during the update that fires when a routine
|
||||
// is deleted — and reading any persisted property (even `id`) on a deleted
|
||||
// `@Model` traps. `isDeleted` alone misses a deletion that has already been
|
||||
// saved (the model unregisters: `isDeleted` false again, `modelContext` nil,
|
||||
// reads still trap), so check both. An empty id maps to no live split, so
|
||||
// `body` shows the "Split Unavailable" state and dismisses; the row is on its
|
||||
// reads still trap), so check both. An empty id maps to no live routine, so
|
||||
// `body` shows the "Routine Unavailable" state and dismisses; the row is on its
|
||||
// way out anyway.
|
||||
let live = !split.isDeleted && split.modelContext != nil
|
||||
_splitID = State(initialValue: live ? split.id : "")
|
||||
let live = !routine.isDeleted && routine.modelContext != nil
|
||||
_routineID = State(initialValue: live ? routine.id : "")
|
||||
}
|
||||
|
||||
private var split: Split? {
|
||||
let id = sync.currentSplitID(for: splitID)
|
||||
return splits.first { $0.id == id }
|
||||
private var routine: Routine? {
|
||||
let id = sync.currentRoutineID(for: routineID)
|
||||
return routines.first { $0.id == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let split {
|
||||
content(for: split)
|
||||
if let routine {
|
||||
content(for: routine)
|
||||
} else {
|
||||
// The id we held no longer maps to a live split (deleted on another
|
||||
// The id we held no longer maps to a live routine (deleted on another
|
||||
// device, or a transient mid-clone frame). Show nothing and leave.
|
||||
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
|
||||
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
|
||||
.task { dismiss() }
|
||||
}
|
||||
}
|
||||
// Editing this split (or any of its exercises, all reached from here) parks any
|
||||
// active watch run sourced from it — matched by splitID — so the watch can't keep
|
||||
// Editing this routine (or any of its exercises, all reached from here) parks any
|
||||
// active watch run sourced from it — matched by routineID — so the watch can't keep
|
||||
// performing an exercise whose plan we're reconfiguring.
|
||||
.onAppear { services.watchBridge.setEditingSplit(sync.currentSplitID(for: splitID)) }
|
||||
.onDisappear { services.watchBridge.setEditingSplit(nil) }
|
||||
.onAppear { services.watchBridge.setEditingRoutine(sync.currentRoutineID(for: routineID)) }
|
||||
.onDisappear { services.watchBridge.setEditingRoutine(nil) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for split: Split) -> some View {
|
||||
private func content(for routine: Routine) -> some View {
|
||||
Form {
|
||||
Section(header: Text("What is a Split?")) {
|
||||
Text("A \"split\" is simply how you divide (or \"split up\") your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||
Section(header: Text("What is a Routine?")) {
|
||||
Text("A \"routine\" is simply how you divide up your weekly training across different days. Instead of working every muscle group every session, you assign certain muscle groups, movement patterns, or training emphases to specific days.")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
// Headerless — what the exercise list is needs no label; the section
|
||||
// itself keeps the visual separation.
|
||||
if split.exercisesArray.isEmpty {
|
||||
if routine.exercisesArray.isEmpty {
|
||||
Section {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||
@@ -83,7 +83,7 @@ struct SplitDetailView: View {
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(split.exercisesArray) { item in
|
||||
ForEach(routine.exercisesArray) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: item.planSummary(weightUnit: weightUnit)
|
||||
@@ -115,11 +115,11 @@ struct SplitDetailView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(split.name)
|
||||
.navigationTitle(routine.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showingSplitEditSheet = true
|
||||
showingRoutineEditSheet = true
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
@@ -130,13 +130,13 @@ struct SplitDetailView: View {
|
||||
addExercises(names: exerciseNames)
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(isPresented: $showingSplitEditSheet) {
|
||||
SplitAddEditView(split: split) {
|
||||
.sheet(isPresented: $showingRoutineEditSheet) {
|
||||
RoutineAddEditView(routine: routine) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item, split: split)
|
||||
ExerciseAddEditView(exercise: item, routine: routine)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
@@ -155,30 +155,30 @@ struct SplitDetailView: View {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: { item in
|
||||
Text("Remove \"\(item.name)\" from this split?")
|
||||
Text("Remove \"\(item.name)\" from this routine?")
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorder and renumber. Resolves the current split at call time so it
|
||||
/// Reorder and renumber. Resolves the current routine at call time so it
|
||||
/// follows a clone-on-edit.
|
||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||
guard let split else { return }
|
||||
var ordered = split.exercisesArray
|
||||
guard let routine else { return }
|
||||
var ordered = routine.exercisesArray
|
||||
ordered.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
var doc = SplitDocument(from: split)
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.exercises = ordered.enumerated().map { i, ex in
|
||||
var ed = ExerciseDocument(from: ex)
|
||||
ed.order = i
|
||||
return ed
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
guard let split else { return }
|
||||
var doc = SplitDocument(from: split)
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
let existingNames = Set(doc.exercises.map { $0.name })
|
||||
let base = doc.exercises.count
|
||||
let newDocs = names
|
||||
@@ -198,21 +198,21 @@ struct SplitDetailView: View {
|
||||
}
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
|
||||
// If a single exercise was added, open the edit sheet once the cache refreshes.
|
||||
// We rely on the observer to populate it — no direct entity reference needed.
|
||||
}
|
||||
|
||||
private func deleteExercise(_ exercise: Exercise) {
|
||||
guard let split else { return }
|
||||
var doc = SplitDocument(from: split)
|
||||
guard let routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.exercises.removeAll { $0.id == exercise.id }
|
||||
// Re-number orders after removal
|
||||
for i in doc.exercises.indices {
|
||||
doc.exercises[i].order = i
|
||||
}
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(split: doc) }
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//
|
||||
// RoutineListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/25/25 at 6:24 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The Routines library screen, pushed from Settings → Library: a plain list of
|
||||
/// routines, one row each. Tapping a row opens `RoutineDetailView`; long-pressing
|
||||
/// offers Delete (the sole routine-delete affordance); the toolbar's + adds a new
|
||||
/// routine. Starter routines are seeded automatically on the true first run
|
||||
/// (`SyncEngine.autoSeedIfEmpty`), so an empty library just invites creating one.
|
||||
struct RoutineListView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
|
||||
@Query(sort: \Routine.name) private var routines: [Routine]
|
||||
|
||||
@State private var showingAddSheet = false
|
||||
@State private var routineToDelete: Routine?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(routines) { routine in
|
||||
NavigationLink {
|
||||
RoutineDetailView(routine: routine)
|
||||
} label: {
|
||||
RoutineRow(routine: routine)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
routineToDelete = routine
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.overlay {
|
||||
if routines.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Routines Yet",
|
||||
systemImage: "dumbbell.fill",
|
||||
description: Text("Create a routine to organize your workout routine.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Routines")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Add Routine")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
RoutineAddEditView(routine: nil)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Routine?",
|
||||
isPresented: Binding(
|
||||
get: { routineToDelete != nil },
|
||||
set: { if !$0 { routineToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: routineToDelete
|
||||
) { routine in
|
||||
Button("Delete", role: .destructive) {
|
||||
Task { await sync.delete(routine: routine) }
|
||||
routineToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
routineToDelete = nil
|
||||
}
|
||||
} message: { routine in
|
||||
Text("This will permanently delete \"\(routine.name)\" and all its exercises. Past workouts are kept.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single routine row: a small rounded-square badge (the routine's color at low
|
||||
/// opacity, its symbol tinted full-strength) beside the routine name and its
|
||||
/// exercise count.
|
||||
private struct RoutineRow: View {
|
||||
var routine: Routine
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: routine.systemImage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(routineColor)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(routineColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(routine.name)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("\(routine.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var routineColor: Color {
|
||||
Color.color(from: routine.color)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Developer-facing tool: scans iCloud Drive for splits/workouts that are exact
|
||||
/// Developer-facing tool: scans iCloud Drive for routines/workouts that are exact
|
||||
/// content duplicates (see `DuplicateCleanupPlanner`) — typically the residue of
|
||||
/// testing, a restore, or a sync hiccup — and offers to delete the extras. Never
|
||||
/// touches a split referenced by a workout, a bundled starter split, or an
|
||||
/// touches a routine referenced by a workout, a bundled starter routine, or an
|
||||
/// in-progress workout.
|
||||
struct DuplicateCleanupView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@@ -21,7 +21,7 @@ struct DuplicateCleanupView: View {
|
||||
}
|
||||
|
||||
private struct CleanupSummary {
|
||||
var splitsDeleted: Int
|
||||
var routinesDeleted: Int
|
||||
var workoutsDeleted: Int
|
||||
var skipped: Int
|
||||
}
|
||||
@@ -58,7 +58,7 @@ struct DuplicateCleanupView: View {
|
||||
pendingPlan = nil
|
||||
}
|
||||
} message: { _ in
|
||||
Text("This permanently deletes the duplicate copies shown below. Referenced splits, starter splits, and in-progress workouts are always kept.")
|
||||
Text("This permanently deletes the duplicate copies shown below. Referenced routines, starter routines, and in-progress workouts are always kept.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ struct DuplicateCleanupView: View {
|
||||
Label("Scan for Duplicates", systemImage: "magnifyingglass")
|
||||
}
|
||||
} footer: {
|
||||
Text("Looks for splits and workouts that are exact content duplicates — for example, left over from testing or a sync hiccup — and offers to delete the extra copies. Splits referenced by a workout, starter splits, and in-progress workouts are never deleted.")
|
||||
Text("Looks for routines and workouts that are exact content duplicates — for example, left over from testing or a sync hiccup — and offers to delete the extra copies. Routines referenced by a workout, starter routines, and in-progress workouts are never deleted.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,10 +110,10 @@ struct DuplicateCleanupView: View {
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
if !plan.splitGroups.isEmpty {
|
||||
Section("Duplicate Splits") {
|
||||
ForEach(plan.splitGroups) { group in
|
||||
splitGroupRow(group)
|
||||
if !plan.routineGroups.isEmpty {
|
||||
Section("Duplicate Routines") {
|
||||
ForEach(plan.routineGroups) { group in
|
||||
routineGroupRow(group)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ struct DuplicateCleanupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func splitGroupRow(_ group: DuplicateCleanupPlan.SplitGroup) -> some View {
|
||||
private func routineGroupRow(_ group: DuplicateCleanupPlan.RoutineGroup) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(group.keep) { doc in
|
||||
memberRow(name: doc.name, detail: "\(doc.exercises.count) exercises", id: doc.id, isKeep: true)
|
||||
@@ -154,14 +154,14 @@ struct DuplicateCleanupView: View {
|
||||
private func workoutGroupRow(_ group: DuplicateCleanupPlan.WorkoutGroup) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
memberRow(
|
||||
name: group.keep.splitName ?? "(no split)",
|
||||
name: group.keep.routineName ?? "(no routine)",
|
||||
detail: group.keep.start.formattedDate(),
|
||||
id: group.keep.id,
|
||||
isKeep: true
|
||||
)
|
||||
ForEach(group.delete) { doc in
|
||||
memberRow(
|
||||
name: doc.splitName ?? "(no split)",
|
||||
name: doc.routineName ?? "(no routine)",
|
||||
detail: doc.start.formattedDate(),
|
||||
id: doc.id,
|
||||
isKeep: false
|
||||
@@ -237,7 +237,7 @@ struct DuplicateCleanupView: View {
|
||||
List {
|
||||
Section {
|
||||
Label(
|
||||
"Deleted \(summary.splitsDeleted) split\(summary.splitsDeleted == 1 ? "" : "s"), \(summary.workoutsDeleted) workout\(summary.workoutsDeleted == 1 ? "" : "s"). \(summary.skipped) skipped (referenced or active).",
|
||||
"Deleted \(summary.routinesDeleted) routine\(summary.routinesDeleted == 1 ? "" : "s"), \(summary.workoutsDeleted) workout\(summary.workoutsDeleted == 1 ? "" : "s"). \(summary.skipped) skipped (referenced or active).",
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
.foregroundStyle(.green)
|
||||
@@ -273,7 +273,7 @@ struct DuplicateCleanupView: View {
|
||||
phase = .deleting
|
||||
let result = await sync.performCleanup(plan)
|
||||
phase = .done(CleanupSummary(
|
||||
splitsDeleted: result.splitsDeleted,
|
||||
routinesDeleted: result.routinesDeleted,
|
||||
workoutsDeleted: result.workoutsDeleted,
|
||||
skipped: result.skipped
|
||||
))
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// GoalsListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A reorderable list of the practice-goal kinds. The chosen order is persisted via
|
||||
/// `GoalKind.saveOrder` and drives how schedules group on the Today screen. Pushed
|
||||
/// from Settings → Library, so it relies on Settings' own NavigationStack.
|
||||
struct GoalsListView: View {
|
||||
@State private var order: [GoalKind] = []
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(order) { kind in
|
||||
Label(kind.displayName, systemImage: kind.systemImage)
|
||||
.foregroundStyle(Color.color(from: kind.colorName))
|
||||
}
|
||||
.onMove(perform: move)
|
||||
} footer: {
|
||||
Text("Goals group your schedules on the Today screen. Drag to reorder.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Goals")
|
||||
.toolbar {
|
||||
EditButton()
|
||||
}
|
||||
.onAppear {
|
||||
if order.isEmpty { order = GoalKind.orderedCases() }
|
||||
}
|
||||
}
|
||||
|
||||
private func move(from source: IndexSet, to destination: Int) {
|
||||
order.move(fromOffsets: source, toOffset: destination)
|
||||
GoalKind.saveOrder(order)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ struct SettingsView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
@Query private var splits: [Split]
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
@AppStorage("restSeconds") private var restSeconds: Int = 45
|
||||
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
|
||||
@@ -78,12 +78,23 @@ struct SettingsView: View {
|
||||
// MARK: - Library Section
|
||||
Section(header: Text("Library")) {
|
||||
NavigationLink {
|
||||
SplitListView()
|
||||
GoalsListView()
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Splits", systemImage: "dumbbell.fill")
|
||||
Label("Goals", systemImage: "target")
|
||||
Spacer()
|
||||
Text("\(splits.count)")
|
||||
Text("\(GoalKind.allCases.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
RoutineListView()
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Routines", systemImage: "dumbbell.fill")
|
||||
Spacer()
|
||||
Text("\(routines.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +111,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Starter Splits Section
|
||||
// MARK: - Starter Routines Section
|
||||
Section {
|
||||
Button {
|
||||
Task {
|
||||
@@ -109,12 +120,12 @@ struct SettingsView: View {
|
||||
let n = await sync.restoreSeeds()
|
||||
isRestoringSeeds = false
|
||||
restoreSeedsMessage = n == 0
|
||||
? "All starter splits are already present."
|
||||
: "Restored \(n) starter split\(n == 1 ? "" : "s")."
|
||||
? "All starter routines are already present."
|
||||
: "Restored \(n) starter routine\(n == 1 ? "" : "s")."
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Restore Starter Splits", systemImage: "arrow.counterclockwise")
|
||||
Label("Restore Starter Routines", systemImage: "arrow.counterclockwise")
|
||||
if isRestoringSeeds {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
@@ -123,12 +134,12 @@ struct SettingsView: View {
|
||||
}
|
||||
.disabled(isRestoringSeeds || sync.iCloudStatus != .available)
|
||||
} header: {
|
||||
Text("Starter Splits")
|
||||
Text("Starter Routines")
|
||||
} footer: {
|
||||
if let restoreSeedsMessage {
|
||||
Text(restoreSeedsMessage)
|
||||
} else {
|
||||
Text("Brings back the bundled starter splits you've deleted. Splits you've edited or created are never touched.")
|
||||
Text("Brings back the bundled starter routines you've deleted. Routines you've edited or created are never touched.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
//
|
||||
// SplitListView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/25/25 at 6:24 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The Splits library screen, pushed from Settings → Library: a plain list of
|
||||
/// splits, one row each. Tapping a row opens `SplitDetailView`; long-pressing
|
||||
/// offers Delete (the sole split-delete affordance); the toolbar's + adds a new
|
||||
/// split. Starter splits are seeded automatically on the true first run
|
||||
/// (`SyncEngine.autoSeedIfEmpty`), so an empty library just invites creating one.
|
||||
struct SplitListView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
|
||||
@Query(sort: \Split.name) private var splits: [Split]
|
||||
|
||||
@State private var showingAddSheet = false
|
||||
@State private var splitToDelete: Split?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(splits) { split in
|
||||
NavigationLink {
|
||||
SplitDetailView(split: split)
|
||||
} label: {
|
||||
SplitRow(split: split)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
splitToDelete = split
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.overlay {
|
||||
if splits.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Splits Yet",
|
||||
systemImage: "dumbbell.fill",
|
||||
description: Text("Create a split to organize your workout routine.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Splits")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddSheet = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Add Split")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
SplitAddEditView(split: nil)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Split?",
|
||||
isPresented: Binding(
|
||||
get: { splitToDelete != nil },
|
||||
set: { if !$0 { splitToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: splitToDelete
|
||||
) { split in
|
||||
Button("Delete", role: .destructive) {
|
||||
Task { await sync.delete(split: split) }
|
||||
splitToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
splitToDelete = nil
|
||||
}
|
||||
} message: { split in
|
||||
Text("This will permanently delete \"\(split.name)\" and all its exercises. Past workouts are kept.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single split row: a small rounded-square badge (the split's color at low
|
||||
/// opacity, its symbol tinted full-strength) beside the split name and its
|
||||
/// exercise count.
|
||||
private struct SplitRow: View {
|
||||
var split: Split
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: split.systemImage)
|
||||
.font(.title3)
|
||||
.foregroundStyle(splitColor)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(splitColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(split.name)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private var splitColor: Color {
|
||||
Color.color(from: split.color)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// DayPickerSheet.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// The calendar toolbar button's sheet: jumps the Today board to any day. Days with
|
||||
/// at least one logged workout carry a small dot. Built on `UICalendarView` rather
|
||||
/// than the SwiftUI graphical `DatePicker` solely because only the UIKit calendar
|
||||
/// supports per-day decorations. Picking a day (or "Today") dismisses immediately —
|
||||
/// this is navigation, not a form, so there's nothing to confirm.
|
||||
struct DayPickerSheet: View {
|
||||
@Binding var selectedDate: Date
|
||||
/// Day-precision (year/month/day) components of the days to decorate.
|
||||
let workoutDays: Set<DateComponents>
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
DayCalendarView(selectedDate: $selectedDate, workoutDays: workoutDays) {
|
||||
dismiss()
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.navigationTitle("Go to Day")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Today") {
|
||||
selectedDate = Date()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
}
|
||||
|
||||
/// `UICalendarView` wrapper: single-date selection plus a dot decoration on each
|
||||
/// day in `workoutDays`.
|
||||
private struct DayCalendarView: UIViewRepresentable {
|
||||
@Binding var selectedDate: Date
|
||||
let workoutDays: Set<DateComponents>
|
||||
/// Called after a day is picked — the sheet dismisses itself here.
|
||||
var onPick: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
|
||||
func makeUIView(context: Context) -> UICalendarView {
|
||||
let view = UICalendarView()
|
||||
view.calendar = Calendar.current
|
||||
view.delegate = context.coordinator
|
||||
let selection = UICalendarSelectionSingleDate(delegate: context.coordinator)
|
||||
selection.setSelected(
|
||||
Calendar.current.dateComponents([.year, .month, .day], from: selectedDate),
|
||||
animated: false
|
||||
)
|
||||
view.selectionBehavior = selection
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UICalendarView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator: NSObject, UICalendarViewDelegate, UICalendarSelectionSingleDateDelegate {
|
||||
var parent: DayCalendarView
|
||||
|
||||
init(_ parent: DayCalendarView) { self.parent = parent }
|
||||
|
||||
func calendarView(_ calendarView: UICalendarView,
|
||||
decorationFor dateComponents: DateComponents) -> UICalendarView.Decoration? {
|
||||
// The view hands back components richer than year/month/day (era, calendar,
|
||||
// …) — reduce to day precision so set membership compares equal.
|
||||
var day = DateComponents()
|
||||
day.year = dateComponents.year
|
||||
day.month = dateComponents.month
|
||||
day.day = dateComponents.day
|
||||
return parent.workoutDays.contains(day) ? .default(color: .systemGreen, size: .small) : nil
|
||||
}
|
||||
|
||||
func dateSelection(_ selection: UICalendarSelectionSingleDate,
|
||||
didSelectDate dateComponents: DateComponents?) {
|
||||
guard let dateComponents, let date = Calendar.current.date(from: dateComponents) else { return }
|
||||
parent.selectedDate = date
|
||||
parent.onPick()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//
|
||||
// ScheduleAddEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// Add / edit a schedule: pick a routine, tag it with a goal, and set how often it
|
||||
/// recurs. Fields are snapshotted in `init` so the sheet never holds the live entity.
|
||||
struct ScheduleAddEditView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||
private var routines: [Routine]
|
||||
|
||||
/// nil id → add mode; a value → edit mode (id / order / createdAt preserved).
|
||||
@State private var scheduleID: String?
|
||||
@State private var order: Int
|
||||
@State private var createdAt: Date
|
||||
@State private var routineID: String
|
||||
/// The schedule's routine name when it was created — surfaced when that routine
|
||||
/// has since been deleted so the user knows what they're reassigning.
|
||||
@State private var originalRoutineName: String
|
||||
@State private var goal: GoalKind?
|
||||
@State private var recurrence: ScheduleRecurrence
|
||||
@State private var weekdays: Set<Int>
|
||||
@State private var timesPerWeek: Int
|
||||
@State private var date: Date
|
||||
|
||||
/// Calendar weekday numbers in Monday-first display order (Mon…Sat = 2…7, Sun = 1).
|
||||
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
|
||||
/// Gregorian short names, 0-indexed from Sunday — weekday `n` is `symbols[n - 1]`.
|
||||
private let weekdaySymbols = Calendar(identifier: .gregorian).shortWeekdaySymbols
|
||||
|
||||
/// `defaultDate` seeds the `.once` day when adding — the Today board passes the day
|
||||
/// currently on screen, so "navigate to a day, tap +" plans a one-off for that day.
|
||||
init(schedule: Schedule? = nil, defaultDate: Date = Date()) {
|
||||
_scheduleID = State(initialValue: schedule?.id)
|
||||
_order = State(initialValue: schedule?.order ?? 0)
|
||||
_createdAt = State(initialValue: schedule?.createdAt ?? Date())
|
||||
_routineID = State(initialValue: schedule?.routineID ?? "")
|
||||
_originalRoutineName = State(initialValue: schedule?.routineName ?? "")
|
||||
_goal = State(initialValue: schedule?.goalKind)
|
||||
_recurrence = State(initialValue: schedule?.recurrenceEnum ?? .once)
|
||||
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
|
||||
_timesPerWeek = State(initialValue: schedule?.timesPerWeek ?? 3)
|
||||
_date = State(initialValue: schedule?.date ?? defaultDate)
|
||||
}
|
||||
|
||||
private var isEditing: Bool { scheduleID != nil }
|
||||
|
||||
/// The live routine currently selected; nil when the stored id no longer resolves.
|
||||
private var selectedRoutine: Routine? { routines.first { $0.id == routineID } }
|
||||
|
||||
private var canSave: Bool {
|
||||
guard selectedRoutine != nil else { return false }
|
||||
if recurrence == .fixedDays { return !weekdays.isEmpty }
|
||||
return true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
routineSection
|
||||
goalSection
|
||||
recurrenceSection
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save", action: save).disabled(!canSave)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Default to the first routine when adding (the @Query isn't populated at init).
|
||||
if routineID.isEmpty { routineID = routines.first?.id ?? "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var routineSection: some View {
|
||||
Section {
|
||||
Picker("Routine", selection: $routineID) {
|
||||
ForEach(routines) { routine in
|
||||
Text(routine.name).tag(routine.id)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Routine")
|
||||
} footer: {
|
||||
if selectedRoutine == nil, !originalRoutineName.isEmpty {
|
||||
Text("\"\(originalRoutineName)\" is no longer available. Choose a routine.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var goalSection: some View {
|
||||
Section {
|
||||
Picker("Goal", selection: $goal) {
|
||||
Text("None").tag(GoalKind?.none)
|
||||
ForEach(GoalKind.orderedCases()) { kind in
|
||||
Label(kind.displayName, systemImage: kind.systemImage)
|
||||
.foregroundStyle(Color.color(from: kind.colorName))
|
||||
.tag(GoalKind?.some(kind))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.labelsHidden()
|
||||
} header: {
|
||||
Text("Goal")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var recurrenceSection: some View {
|
||||
Section {
|
||||
Picker("Repeat", selection: $recurrence) {
|
||||
ForEach(ScheduleRecurrence.allCases, id: \.self) { r in
|
||||
Text(r.displayName).tag(r)
|
||||
}
|
||||
}
|
||||
|
||||
switch recurrence {
|
||||
case .once:
|
||||
DatePicker("Date", selection: $date, displayedComponents: .date)
|
||||
case .daily:
|
||||
EmptyView()
|
||||
case .fixedDays:
|
||||
weekdayPicker
|
||||
case .frequency:
|
||||
Stepper(value: $timesPerWeek, in: 1...7) {
|
||||
Text("\(timesPerWeek)× per week")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Repeat")
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of toggleable weekday chips, Monday-first.
|
||||
private var weekdayPicker: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(weekdayOrder, id: \.self) { n in
|
||||
let on = weekdays.contains(n)
|
||||
Button {
|
||||
if on { weekdays.remove(n) } else { weekdays.insert(n) }
|
||||
} label: {
|
||||
Text(weekdaySymbols[n - 1])
|
||||
.font(.caption.weight(.semibold))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(on ? Color.accentColor : Color.secondary.opacity(0.15), in: Capsule())
|
||||
.foregroundStyle(on ? Color.white : Color.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
// MARK: - Save
|
||||
|
||||
private func save() {
|
||||
guard let routine = selectedRoutine else { return }
|
||||
let doc = ScheduleDocument(
|
||||
schemaVersion: ScheduleDocument.currentSchemaVersion,
|
||||
id: scheduleID ?? ULID.make(),
|
||||
routineID: routine.id,
|
||||
routineName: routine.name,
|
||||
goal: goal?.rawValue,
|
||||
recurrence: recurrence.rawValue,
|
||||
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
|
||||
timesPerWeek: recurrence == .frequency ? timesPerWeek : nil,
|
||||
date: recurrence == .once ? date : nil,
|
||||
order: isEditing ? order : nextOrder(),
|
||||
createdAt: createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
Task { await sync.save(schedule: doc) }
|
||||
dismiss()
|
||||
}
|
||||
|
||||
/// One past the highest existing schedule order — appends a new schedule to the end.
|
||||
private func nextOrder() -> Int {
|
||||
let existing = (try? modelContext.fetch(FetchDescriptor<Schedule>())) ?? []
|
||||
return (existing.map(\.order).max() ?? -1) + 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//
|
||||
// TodayView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The Today board — the today-projection of the user's schedules, grouped by goal.
|
||||
/// A schedule row shows its routine, its recurrence, and today's status for that
|
||||
/// routine; tapping starts (or opens) that routine's workout for today.
|
||||
struct TodayView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
|
||||
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
|
||||
private var schedules: [Schedule]
|
||||
|
||||
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||
private var routines: [Routine]
|
||||
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
|
||||
/// ID of the workout to push into — a just-started one (the navigator polls for
|
||||
/// its cache entity) or an existing today-workout (resolves immediately). One
|
||||
/// id-based path for both, so no destination ever retains a `@Model`.
|
||||
@State private var pendingWorkoutID: String?
|
||||
@State private var scheduleToEdit: Schedule?
|
||||
@State private var showingAddSchedule = false
|
||||
@State private var showingDayPicker = false
|
||||
|
||||
/// The day the board is projecting — today by default; the calendar toolbar
|
||||
/// button navigates to any other day.
|
||||
@State private var selectedDate = Date()
|
||||
|
||||
private var isViewingToday: Bool { selectedDate.isSameDay(as: Date()) }
|
||||
|
||||
/// The viewed day is strictly before today (day-precision).
|
||||
private var isViewingPastDay: Bool { selectedDate < Calendar.current.startOfDay(for: Date()) }
|
||||
|
||||
/// Schedules that belong on the viewed day's board: recurring ones always show
|
||||
/// (the board displays their recurrence, not a due-filter — yet); a one-off shows
|
||||
/// only on its own day. A dateless `.once` (shouldn't happen) shows everywhere
|
||||
/// rather than nowhere.
|
||||
private var visibleSchedules: [Schedule] {
|
||||
schedules.filter { schedule in
|
||||
guard schedule.recurrenceEnum == .once, let date = schedule.date else { return true }
|
||||
return date.isSameDay(as: selectedDate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-empty goal groups in the user's chosen goal order.
|
||||
private var goalGroups: [(kind: GoalKind, schedules: [Schedule])] {
|
||||
GoalKind.orderedCases().compactMap { kind in
|
||||
let matches = visibleSchedules.filter { $0.goalKind == kind }
|
||||
return matches.isEmpty ? nil : (kind, matches)
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedules with no goal, or a goal raw value this version doesn't know.
|
||||
private var unassignedSchedules: [Schedule] {
|
||||
visibleSchedules.filter { $0.goalKind == nil }
|
||||
}
|
||||
|
||||
/// Motivational openers for the board title — each reads as a phrase completed
|
||||
/// by "Today" ("Seize Today"). One per day, rotating by day-of-era: stable all
|
||||
/// day, fresh the next.
|
||||
private static let greetings: [String] = [
|
||||
"Seize",
|
||||
"Own",
|
||||
"Win",
|
||||
"Conquer",
|
||||
"Show Up",
|
||||
"Stay the Course",
|
||||
"Make It Count",
|
||||
"Do the Work",
|
||||
"Start Strong",
|
||||
"No Excuses",
|
||||
"Begin Again",
|
||||
"Keep the Streak",
|
||||
]
|
||||
|
||||
private var greetingLine: String {
|
||||
let day = Calendar.current.ordinality(of: .day, in: .era, for: Date()) ?? 0
|
||||
return Self.greetings[day % Self.greetings.count]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(goalGroups, id: \.kind) { group in
|
||||
Section {
|
||||
ForEach(group.schedules) { scheduleRow($0) }
|
||||
} header: {
|
||||
Label(group.kind.displayName, systemImage: group.kind.systemImage)
|
||||
.foregroundStyle(Color.color(from: group.kind.colorName))
|
||||
}
|
||||
}
|
||||
|
||||
if !unassignedSchedules.isEmpty {
|
||||
Section("Unassigned") {
|
||||
ForEach(unassignedSchedules) { scheduleRow($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if schedules.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Schedules Yet",
|
||||
systemImage: "calendar.badge.plus",
|
||||
description: Text("Plan your week by adding a schedule for a routine.")
|
||||
)
|
||||
} else if visibleSchedules.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Nothing Planned",
|
||||
systemImage: "calendar",
|
||||
description: Text("Nothing is scheduled for this day. Tap + to plan something.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(isViewingToday ? "\(greetingLine) Today" : selectedDate.formatDate())
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showingDayPicker = true
|
||||
} label: {
|
||||
Label("Go to Day", systemImage: "calendar")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingAddSchedule = true
|
||||
} label: {
|
||||
Label("Add Schedule", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Row taps land here for both cases: a fresh start (entity arrives a beat
|
||||
// after the file write) and an existing today-workout (resolves at once).
|
||||
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
SyncStatusBanner()
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.sheet(isPresented: $showingDayPicker) {
|
||||
DayPickerSheet(selectedDate: $selectedDate, workoutDays: workoutDays)
|
||||
}
|
||||
.sheet(isPresented: $showingAddSchedule) {
|
||||
// Seed a would-be one-off with the day on screen, so navigating to a
|
||||
// day and tapping + plans that day.
|
||||
ScheduleAddEditView(defaultDate: selectedDate)
|
||||
}
|
||||
.sheet(item: $scheduleToEdit) { schedule in
|
||||
ScheduleAddEditView(schedule: schedule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Row
|
||||
|
||||
private func scheduleRow(_ schedule: Schedule) -> some View {
|
||||
let routine = resolvedRoutine(for: schedule)
|
||||
let today = todaysWorkout(for: schedule)
|
||||
return Button {
|
||||
openOrStart(schedule: schedule, routine: routine, today: today)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
|
||||
.font(.title3)
|
||||
.foregroundStyle(iconColor(routine: routine, schedule: schedule))
|
||||
.frame(width: 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(routine?.name ?? schedule.routineName)
|
||||
.foregroundStyle(.primary)
|
||||
Text(schedule.recurrenceSummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let today {
|
||||
todayStatus(for: today)
|
||||
} else if isViewingPastDay {
|
||||
skippedTag
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.tint(.primary)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
Task { await sync.delete(schedule: schedule) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
|
||||
Button {
|
||||
scheduleToEdit = schedule
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Today's status decoration: a completed run shows a green check + its finish
|
||||
/// time; an unfinished one shows an orange "In Progress" tag.
|
||||
@ViewBuilder
|
||||
private func todayStatus(for workout: Workout) -> some View {
|
||||
if workout.status == .completed {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text((workout.end ?? workout.start).formattedTime())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text("In Progress")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.orange.opacity(0.15), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// Signpost on a past-day schedule row with no logged workout. Deliberately
|
||||
/// undifferentiated for now — a later pass will distinguish *abandoned* from an
|
||||
/// explicit skip carrying an excuse ("sick day"); see UX-REDESIGN.md.
|
||||
private var skippedTag: some View {
|
||||
Text("Skipped")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(.secondary.opacity(0.15), in: Capsule())
|
||||
}
|
||||
|
||||
// MARK: - Resolution helpers
|
||||
|
||||
/// The live routine this schedule points at, following the seed clone-on-edit
|
||||
/// redirect; nil when the routine has been deleted.
|
||||
private func resolvedRoutine(for schedule: Schedule) -> Routine? {
|
||||
let liveID = sync.currentRoutineID(for: schedule.routineID)
|
||||
return routines.first { $0.id == liveID }
|
||||
}
|
||||
|
||||
/// The most recent workout started on the viewed day for this schedule's routine
|
||||
/// (comparing through the clone redirect on both sides). `workouts` is
|
||||
/// start-descending, so the first match is the newest.
|
||||
private func todaysWorkout(for schedule: Schedule) -> Workout? {
|
||||
let liveRoutineID = sync.currentRoutineID(for: schedule.routineID)
|
||||
return workouts.first { workout in
|
||||
guard let rid = workout.routineID, workout.start.isSameDay(as: selectedDate) else { return false }
|
||||
return sync.currentRoutineID(for: rid) == liveRoutineID
|
||||
}
|
||||
}
|
||||
|
||||
/// Day-precision (year/month/day) components of every day with at least one
|
||||
/// logged workout — the day picker decorates these with a dot.
|
||||
private var workoutDays: Set<DateComponents> {
|
||||
Set(workouts.map { Calendar.current.dateComponents([.year, .month, .day], from: $0.start) })
|
||||
}
|
||||
|
||||
/// Row icon color: the routine's own color, or the goal's color as a generic
|
||||
/// fallback when the routine is gone.
|
||||
private func iconColor(routine: Routine?, schedule: Schedule) -> Color {
|
||||
if let routine { return Color.color(from: routine.color) }
|
||||
return Color.color(from: schedule.goalKind?.colorName ?? "")
|
||||
}
|
||||
|
||||
/// Tap: open the viewed day's workout if one exists, otherwise start a fresh one
|
||||
/// from the resolved routine — but only when the board is on today, since a start
|
||||
/// always happens *now*. Does nothing on other days without a workout, or for an
|
||||
/// orphaned schedule (no live routine).
|
||||
private func openOrStart(schedule: Schedule, routine: Routine?, today: Workout?) {
|
||||
if let today {
|
||||
guard !today.isDeleted else { return }
|
||||
pendingWorkoutID = today.id
|
||||
} else if isViewingToday, let routine {
|
||||
Task {
|
||||
pendingWorkoutID = await WorkoutStarter.start(routine: routine, sync: sync)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ struct ExerciseProgressView: View {
|
||||
|
||||
/// True when this run was auto-advanced into from the previous exercise (flow mode).
|
||||
/// It suppresses the Ready page and begins the exercise immediately on appear — the
|
||||
/// whole point of a hands-free flowing split.
|
||||
/// whole point of a hands-free flowing routine.
|
||||
let enteredViaFlow: Bool
|
||||
|
||||
/// Invoked at the terminal rest's end in flow mode to hand off to the next exercise;
|
||||
@@ -196,11 +196,11 @@ struct ExerciseProgressView: View {
|
||||
/// appear, hands-free.
|
||||
private var showsReady: Bool { !enteredViaFlow }
|
||||
|
||||
/// This split runs as a continuous flow — finishing one exercise rests, then
|
||||
/// This routine runs as a continuous flow — finishing one exercise rests, then
|
||||
/// auto-advances into the next — rather than returning to the list per exercise.
|
||||
private var autoAdvance: Bool { doc.autoAdvance == true }
|
||||
|
||||
/// Rest length for this run: the split's per-split override, else the global default.
|
||||
/// Rest length for this run: the routine's per-routine override, else the global default.
|
||||
/// Governs both between-set rests and (in flow mode) the between-exercise rest.
|
||||
private var effectiveRest: Int { max(1, doc.restSeconds ?? restSeconds) }
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ struct ExerciseView: View {
|
||||
|
||||
/// Apply edited machine settings: update the log's plan-time snapshot (persisted
|
||||
/// with the workout), then write the same settings back to the originating
|
||||
/// split's exercise as the durable default — same sequencing as the workout
|
||||
/// routine's exercise as the durable default — same sequencing as the workout
|
||||
/// list's machine sheet.
|
||||
private func applyMachineSettings(_ settings: [MachineSetting]) {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
||||
@@ -195,10 +195,10 @@ struct ExerciseView: View {
|
||||
doc.updatedAt = Date()
|
||||
let snapshot = doc
|
||||
let exerciseName = doc.logs[i].exerciseName
|
||||
let splitID = doc.splitID
|
||||
let routineID = doc.routineID
|
||||
Task {
|
||||
await sync.save(workout: snapshot)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,13 +163,13 @@ struct PlanEditView: View {
|
||||
let exerciseName = doc.logs[i].exerciseName
|
||||
let workoutDoc = doc
|
||||
|
||||
// 2) Mirror the plan onto the matching exercise in the split template,
|
||||
// 2) Mirror the plan onto the matching exercise in the routine template,
|
||||
// following the seed clone-on-edit redirect so a prior fork doesn't
|
||||
// silently break the mirror.
|
||||
var splitDoc: SplitDocument?
|
||||
if let splitID = doc.splitID,
|
||||
let split = CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext) {
|
||||
var sDoc = SplitDocument(from: split)
|
||||
var routineDoc: RoutineDocument?
|
||||
if let routineID = doc.routineID,
|
||||
let routine = CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID), in: modelContext) {
|
||||
var sDoc = RoutineDocument(from: routine)
|
||||
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
|
||||
sDoc.exercises[ei].sets = sets
|
||||
sDoc.exercises[ei].reps = reps
|
||||
@@ -177,14 +177,14 @@ struct PlanEditView: View {
|
||||
sDoc.exercises[ei].durationSeconds = totalSeconds
|
||||
sDoc.exercises[ei].loadType = selectedLoadType.rawValue
|
||||
sDoc.updatedAt = Date()
|
||||
splitDoc = sDoc
|
||||
routineDoc = sDoc
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
await sync.save(workout: workoutDoc)
|
||||
if let splitDoc {
|
||||
await sync.save(split: splitDoc)
|
||||
if let routineDoc {
|
||||
await sync.save(routine: routineDoc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Thin coordinator that lets a single pushed run walk across exercises in a flow-mode
|
||||
/// split (`autoAdvance`). It owns which log is on screen and swaps it when the running
|
||||
/// routine (`autoAdvance`). It owns which log is on screen and swaps it when the running
|
||||
/// `ExerciseProgressView` finishes an exercise; `.id(currentLogID)` rebuilds the child
|
||||
/// fresh for each exercise, so the delicate per-exercise run/mirror machinery stays
|
||||
/// exactly as it is — this only automates the "open the next exercise" step.
|
||||
///
|
||||
/// For a non-flow split nothing ever calls `advance`, so this is transparent: it presents
|
||||
/// For a non-flow routine nothing ever calls `advance`, so this is transparent: it presents
|
||||
/// one exercise and behaves identically to hosting `ExerciseProgressView` directly.
|
||||
struct RunFlowView: View {
|
||||
@Environment(LiveRunState.self) private var liveRun
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// WorkoutHistoryView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 6:52 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// The full workout history list. Formerly the "Log" tab's root (`WorkoutLogsView`);
|
||||
/// now pushed from the Progress tab, which absorbed history in the four-tab redesign.
|
||||
struct WorkoutHistoryView: 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 itemToDelete: Workout?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(workouts) { workout in
|
||||
NavigationLink {
|
||||
WorkoutLogListView(workout: workout)
|
||||
} label: {
|
||||
CalendarListItem(
|
||||
date: workout.start,
|
||||
title: workout.routineName ?? Routine.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 routines.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("History")
|
||||
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
|
||||
// On the log screen so a stalled save is visible where editing happens.
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
SyncStatusBanner()
|
||||
}
|
||||
.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.workoutHealthDeleter.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,12 @@ struct WorkoutLogListView: View {
|
||||
doc.logs.sorted { $0.order < $1.order }
|
||||
}
|
||||
|
||||
/// The split this workout was started from (for adding more exercises),
|
||||
/// The routine this workout was started from (for adding more exercises),
|
||||
/// following the seed clone-on-edit redirect so a mid-workout fork still
|
||||
/// resolves to the live clone.
|
||||
private var split: Split? {
|
||||
guard let splitID = doc.splitID else { return nil }
|
||||
return CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext)
|
||||
private var routine: Routine? {
|
||||
guard let routineID = doc.routineID else { return nil }
|
||||
return CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID), in: modelContext)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -166,7 +166,7 @@ struct WorkoutLogListView: View {
|
||||
// doc so it shows the latest local state immediately.
|
||||
ExerciseView(workout: workout, logID: route.id, seedDoc: doc)
|
||||
}
|
||||
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||
.navigationTitle(doc.routineName ?? Routine.unnamed)
|
||||
// Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
|
||||
// cache reflects them, so the list shows live status on return.
|
||||
.onChange(of: workout.updatedAt) { _, _ in
|
||||
@@ -185,12 +185,12 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
SplitExercisePickerSheet(
|
||||
split: split,
|
||||
RoutineExercisePickerSheet(
|
||||
routine: routine,
|
||||
existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
|
||||
) { selection in
|
||||
switch selection {
|
||||
case .fromSplit(let exercise): addExerciseFromSplit(exercise)
|
||||
case .fromRoutine(let exercise): addExerciseFromRoutine(exercise)
|
||||
case .fromLibrary(let name): addExerciseFromLibrary(name)
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ struct WorkoutLogListView: View {
|
||||
/// (so the propped-phone mirror cover doesn't stack on top of it).
|
||||
@ViewBuilder
|
||||
private func progressView(logID: String) -> some View {
|
||||
// RunFlowView hosts the run and, in a flow-mode split, auto-advances across
|
||||
// RunFlowView hosts the run and, in a flow-mode routine, auto-advances across
|
||||
// exercises. It owns the live-run mirror wiring (incoming frame filter,
|
||||
// `navigatedRunID`, and the parameterized live-ended signal) since the on-screen
|
||||
// log advances during a flowing run.
|
||||
@@ -321,11 +321,11 @@ struct WorkoutLogListView: View {
|
||||
save()
|
||||
}
|
||||
|
||||
private func addExerciseFromSplit(_ exercise: Exercise) {
|
||||
private func addExerciseFromRoutine(_ exercise: Exercise) {
|
||||
appendExercise(ExerciseDocument(from: exercise))
|
||||
}
|
||||
|
||||
/// Add an exercise that isn't part of this workout's split, picked from the full
|
||||
/// Add an exercise that isn't part of this workout's routine, picked from the full
|
||||
/// exercise library. Seeds a plan from, in priority order: the most recent logged
|
||||
/// set of this exercise (any workout), the library's authored `**Defaults:**`, or
|
||||
/// a plain 3×10 fallback when neither exists.
|
||||
@@ -334,7 +334,7 @@ struct WorkoutLogListView: View {
|
||||
}
|
||||
|
||||
/// Append a freshly-planned exercise to the working doc, persist, and push
|
||||
/// straight into its progress flow. Shared tail of `addExerciseFromSplit` and
|
||||
/// straight into its progress flow. Shared tail of `addExerciseFromRoutine` and
|
||||
/// `addExerciseFromLibrary`.
|
||||
private func appendExercise(_ plan: ExerciseDocument) {
|
||||
let now = Date()
|
||||
@@ -354,7 +354,7 @@ struct WorkoutLogListView: View {
|
||||
addedLog = LogRoute(id: newLog.id)
|
||||
}
|
||||
|
||||
/// Plan defaults for a library exercise with no split entry: copy the most recent
|
||||
/// Plan defaults for a library exercise with no routine entry: copy the most recent
|
||||
/// logged set of this exercise (sets/reps/weight/loadType/duration and machine
|
||||
/// settings) if one exists anywhere; otherwise the library's authored defaults
|
||||
/// (weight always starts at 0 — there's no prior lift to copy); otherwise a plain
|
||||
@@ -396,9 +396,9 @@ struct WorkoutLogListView: View {
|
||||
|
||||
/// Apply edited machine settings from the sheet: update the log's plan-time
|
||||
/// snapshot (persisted with the workout), then write the same settings back to
|
||||
/// the originating split's exercise as the durable default. Sequenced in one
|
||||
/// task so the split's clone-on-edit repoint (which rewrites this workout's
|
||||
/// `splitID`) sees the log edit already persisted rather than clobbering it.
|
||||
/// the originating routine's exercise as the durable default. Sequenced in one
|
||||
/// task so the routine's clone-on-edit repoint (which rewrites this workout's
|
||||
/// `routineID`) sees the log edit already persisted rather than clobbering it.
|
||||
private func applyMachineSettings(_ settings: [MachineSetting], toLogID id: String) {
|
||||
guard let i = doc.logs.firstIndex(where: { $0.id == id }) else { return }
|
||||
doc.logs[i].machineSettings = settings
|
||||
@@ -408,10 +408,10 @@ struct WorkoutLogListView: View {
|
||||
|
||||
let workoutSnapshot = doc
|
||||
let exerciseName = doc.logs[i].exerciseName
|
||||
let splitID = doc.splitID
|
||||
let routineID = doc.routineID
|
||||
Task {
|
||||
await sync.save(workout: workoutSnapshot)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
|
||||
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,12 +579,12 @@ private struct WorkoutLogRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Split Exercise Picker Sheet
|
||||
// MARK: - Routine Exercise Picker Sheet
|
||||
|
||||
struct SplitExercisePickerSheet: View {
|
||||
struct RoutineExercisePickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let split: Split?
|
||||
let routine: Routine?
|
||||
let existingExerciseNames: Set<String>
|
||||
let onSelected: (Selection) -> Void
|
||||
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
||||
@@ -593,31 +593,31 @@ struct SplitExercisePickerSheet: View {
|
||||
/// library section's name list is stable for the sheet's lifetime.
|
||||
@State private var libraryInfoByName: [String: ExerciseInfo] = [:]
|
||||
|
||||
/// A pick from either source: the split path hands back the live `Exercise`
|
||||
/// A pick from either source: the routine path hands back the live `Exercise`
|
||||
/// entity (as today), the library path just the exercise name.
|
||||
enum Selection {
|
||||
case fromSplit(Exercise)
|
||||
case fromRoutine(Exercise)
|
||||
case fromLibrary(String)
|
||||
}
|
||||
|
||||
/// Section 1 — this workout's split, minus exercises already added.
|
||||
private var splitExercises: [Exercise] {
|
||||
guard let split else { return [] }
|
||||
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
|
||||
/// Section 1 — this workout's routine, minus exercises already added.
|
||||
private var routineExercises: [Exercise] {
|
||||
guard let routine else { return [] }
|
||||
return routine.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
|
||||
}
|
||||
|
||||
/// Section 2 — every library exercise not already in the workout and not
|
||||
/// already offered in section 1.
|
||||
private var libraryExerciseNames: [String] {
|
||||
let shown = Set(splitExercises.map(\.name))
|
||||
let shown = Set(routineExercises.map(\.name))
|
||||
return ExerciseMotionLibrary.exerciseNames.filter {
|
||||
!existingExerciseNames.contains($0) && !shown.contains($0)
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredSplitExercises: [Exercise] {
|
||||
guard !searchText.isEmpty else { return splitExercises }
|
||||
return splitExercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
private var filteredRoutineExercises: [Exercise] {
|
||||
guard !searchText.isEmpty else { return routineExercises }
|
||||
return routineExercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
private var filteredLibraryNames: [String] {
|
||||
@@ -628,7 +628,7 @@ struct SplitExercisePickerSheet: View {
|
||||
/// Whether there's anything left to add at all — independent of the current
|
||||
/// search, so a no-results search doesn't get confused with a truly empty sheet.
|
||||
private var hasAnyExercises: Bool {
|
||||
!splitExercises.isEmpty || !libraryExerciseNames.isEmpty
|
||||
!routineExercises.isEmpty || !libraryExerciseNames.isEmpty
|
||||
}
|
||||
|
||||
/// "3 × 12" / "3 × 30 s" — the library's suggested plan for a row's caption.
|
||||
@@ -648,15 +648,15 @@ struct SplitExercisePickerSheet: View {
|
||||
systemImage: "checkmark.circle",
|
||||
description: Text("Every exercise is already part of this workout.")
|
||||
)
|
||||
} else if filteredSplitExercises.isEmpty && filteredLibraryNames.isEmpty {
|
||||
} else if filteredRoutineExercises.isEmpty && filteredLibraryNames.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
List {
|
||||
if !filteredSplitExercises.isEmpty, let split {
|
||||
Section("From \(split.name)") {
|
||||
ForEach(filteredSplitExercises) { exercise in
|
||||
if !filteredRoutineExercises.isEmpty, let routine {
|
||||
Section("From \(routine.name)") {
|
||||
ForEach(filteredRoutineExercises) { exercise in
|
||||
Button {
|
||||
onSelected(.fromSplit(exercise))
|
||||
onSelected(.fromRoutine(exercise))
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
@@ -730,7 +730,7 @@ struct SplitExercisePickerSheet: View {
|
||||
/// machine-based log (non-nil `machineSettings`), so there's no machine toggle —
|
||||
/// just the ordered rows (reusing `MachineSettingsEditor`) plus Save/Cancel. Save
|
||||
/// hands the edited array back to the caller, which persists it to the log and
|
||||
/// writes it back to the split's exercise as the new default. Shared with
|
||||
/// writes it back to the routine's exercise as the new default. Shared with
|
||||
/// `ExerciseView`'s Machine Settings section.
|
||||
struct MachineSettingsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -750,7 +750,7 @@ struct MachineSettingsSheet: View {
|
||||
Form {
|
||||
Section(
|
||||
header: Text("Settings"),
|
||||
footer: Text("Saved to this workout and set as the default for \(exerciseName) in its split.")
|
||||
footer: Text("Saved to this workout and set as the default for \(exerciseName) in its routine.")
|
||||
) {
|
||||
MachineSettingsEditor(settings: $settings)
|
||||
}
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
//
|
||||
// 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?
|
||||
/// ID of the just-started workout; drives the push into its log screen once the
|
||||
/// cache observer delivers the entity a beat after the file write (see
|
||||
/// `navigatesToStartedWorkout`).
|
||||
@State private var pendingWorkoutID: String?
|
||||
|
||||
// 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")
|
||||
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
|
||||
// On the root screen so a stalled save is visible where editing happens.
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
SyncStatusBanner()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
showingSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape.2")
|
||||
}
|
||||
.accessibilityLabel("Settings")
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Start New") {
|
||||
showingSplitPicker.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(isPresented: $showingSplitPicker) {
|
||||
SplitPickerSheet { pendingWorkoutID = $0 }
|
||||
}
|
||||
// Once "Start New" saves a workout, drop straight into its log screen —
|
||||
// the same landing the split's exercise-list start path uses.
|
||||
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
|
||||
.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.workoutHealthDeleter.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
|
||||
|
||||
/// Called with the new workout's id right after it's saved, so the presenting
|
||||
/// screen can navigate into it once the cache catches up.
|
||||
var onStarted: (String) -> Void = { _ in }
|
||||
|
||||
@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 }
|
||||
}
|
||||
|
||||
/// The splits most recently trained, newest first, each with the start date
|
||||
/// of its latest workout — derived from the workout log (`workouts` is
|
||||
/// already sorted by start descending). Each workout's `splitID` follows the
|
||||
/// seed clone-on-edit redirect so a workout started from a since-forked seed
|
||||
/// still credits the live clone. Deduped, capped, and limited to splits that
|
||||
/// still exist.
|
||||
private var recentSplits: [(split: Split, lastStart: Date)] {
|
||||
var seen = Set<String>()
|
||||
var recents: [(split: Split, lastStart: Date)] = []
|
||||
for workout in workouts {
|
||||
guard let splitID = workout.splitID else { continue }
|
||||
let liveID = sync.currentSplitID(for: splitID)
|
||||
guard !seen.contains(liveID) else { continue }
|
||||
seen.insert(liveID)
|
||||
if let split = splits.first(where: { $0.id == liveID }) {
|
||||
recents.append((split, workout.start))
|
||||
if recents.count == 3 { break }
|
||||
}
|
||||
}
|
||||
return recents
|
||||
}
|
||||
|
||||
/// One selectable split row, shared by the Recent and All Splits sections.
|
||||
/// Recent rows pass `lastTrained` to show how long ago the split was run.
|
||||
private func splitRow(_ split: Split, lastTrained: Date? = nil) -> some View {
|
||||
Button {
|
||||
confirmAndStart(with: split)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: split.systemImage)
|
||||
.foregroundColor(Color.color(from: split.color))
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(split.name)
|
||||
if let lastTrained {
|
||||
Text(lastTrained.daysAgoLabel())
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(split.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if !recentSplits.isEmpty {
|
||||
Section("Recent") {
|
||||
ForEach(recentSplits, id: \.split.id) { recent in
|
||||
splitRow(recent.split, lastTrained: recent.lastStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(recentSplits.isEmpty ? "" : "All Splits") {
|
||||
ForEach(splits) { split in
|
||||
splitRow(split)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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,
|
||||
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance
|
||||
)
|
||||
|
||||
// Hand the id back after the save so the presenter can poll the cache and
|
||||
// navigate into the run — mirroring the exercise-list start path.
|
||||
Task {
|
||||
await sync.save(workout: doc)
|
||||
onStarted(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 so the saved Health workout is categorized.
|
||||
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Started-Workout Navigation
|
||||
|
||||
/// Drives a programmatic push into a freshly-started workout's log screen. Both start
|
||||
/// paths — the split picker sheet and a split's exercise list — mint a `WorkoutDocument`,
|
||||
/// write it, then hand its id here; the workout row only materializes once the
|
||||
/// file→observer→cache loop round-trips, so we poll the cache for the entity and push it
|
||||
/// the moment it lands.
|
||||
private struct StartedWorkoutNavigator: ViewModifier {
|
||||
/// The pushed state is a plain value wrapping the run's id — never a retained
|
||||
/// `@Model`. This screen stays pushed for the whole workout, and the run's entity
|
||||
/// can be deleted and re-imported underneath it (observer remove/re-add churn, a
|
||||
/// remote delete); a `@Model` held in `@State` would be *invalidated* by then —
|
||||
/// deleted-and-saved models read `isDeleted == false` yet trap on any property
|
||||
/// access (the TestFlight 2.3 (125) "crashed when watch ended an exercise" report).
|
||||
private struct RunRoute: Identifiable, Hashable { let id: String }
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Binding var pendingWorkoutID: String?
|
||||
@State private var resolvedRun: RunRoute?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.navigationDestination(item: $resolvedRun) { route in
|
||||
// Re-fetch on every build so the destination always maps a live
|
||||
// entity; a re-imported run resolves to its fresh instance.
|
||||
if let workout = CacheMapper.fetchWorkout(id: route.id, in: modelContext) {
|
||||
WorkoutLogListView(workout: workout)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Workout Unavailable",
|
||||
systemImage: "xmark.circle",
|
||||
description: Text("This workout is no longer on this device."))
|
||||
}
|
||||
}
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll for the entity after we write the document (the file→observer→cache loop
|
||||
/// typically completes in well under a second). Clear the pending id once it
|
||||
/// resolves, or silently after ~3 s if it never arrives.
|
||||
private func pollForWorkout(id: String) {
|
||||
Task {
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
if CacheMapper.fetchWorkout(id: id, in: modelContext) != nil {
|
||||
resolvedRun = RunRoute(id: id)
|
||||
pendingWorkoutID = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
pendingWorkoutID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Navigate into a workout's log screen once it appears in the cache after being
|
||||
/// started. Bind to the state you set to the new workout's id right after saving it.
|
||||
func navigatesToStartedWorkout(pendingWorkoutID: Binding<String?>) -> some View {
|
||||
modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID))
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ struct WorkoutSummaryView: View {
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.top, 8)
|
||||
Text(workout.splitName ?? Split.unnamed)
|
||||
Text(workout.routineName ?? Routine.unnamed)
|
||||
.font(.title2.bold())
|
||||
|
||||
WorkoutMetricsView(workout: workout)
|
||||
|
||||
Reference in New Issue
Block a user