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:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
@@ -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
/// fileobservercache 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 fileobservercache 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: namevalue rows, grouped per split when the recorded lists differ.
/// as a card: namevalue 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)
}
+27 -27
View File
@@ -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)
}
}
+138
View File
@@ -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)))
}
}
+134
View File
@@ -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()
}
}
}
@@ -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) }
}
}
}
@@ -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)
}
}
+21 -10
View File
@@ -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.")
}
}
-118
View File
@@ -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)
}
}
+98
View File
@@ -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 (MonSat = 27, 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
}
}
+293
View File
@@ -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)
}
}
}
+2 -2
View File
@@ -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
/// fileobservercache 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 fileobservercache 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)