Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView becomes a Today / Progress / Settings TabView, "Routine" replaces "Split" in every user-facing string and view name (code-level types keep their names), and workout starting moves to shared WorkoutStarter / StartedWorkoutNavigator plumbing. - New Progress tab: weekly goal streaks, workout trends, per-exercise weight progression, achievements, and the full history list (WorkoutLogsView -> WorkoutHistoryView). - Goals: stable categories workouts roll up to, managed from Settings. - New Meditation exercise + starter routine; timed sits record to Apple Health as Mind & Body sessions. Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// RoutinePickerSheet.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Routine Picker Sheet
|
||||
|
||||
struct RoutinePickerSheet: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Called with the new workout's id right after it's saved, so the presenting
|
||||
/// screen can navigate into it once the cache catches up.
|
||||
var onStarted: (String) -> Void = { _ in }
|
||||
|
||||
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
|
||||
private var routines: [Routine]
|
||||
|
||||
@Query(sort: \Workout.start, order: .reverse)
|
||||
private var workouts: [Workout]
|
||||
|
||||
/// Set when the user picks a routine while other workouts are still going — drives the
|
||||
/// "end the current one(s) or run in parallel?" prompt.
|
||||
@State private var routineAwaitingConfirmation: Routine?
|
||||
|
||||
private var activeWorkouts: [Workout] {
|
||||
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||
}
|
||||
|
||||
/// The routines most recently trained, newest first, each with the start date
|
||||
/// of its latest workout — derived from the workout log (`workouts` is
|
||||
/// already sorted by start descending). Each workout's `routineID` follows the
|
||||
/// seed clone-on-edit redirect so a workout started from a since-forked seed
|
||||
/// still credits the live clone. Deduped, capped, and limited to routines that
|
||||
/// still exist.
|
||||
private var recentRoutines: [(routine: Routine, lastStart: Date)] {
|
||||
var seen = Set<String>()
|
||||
var recents: [(routine: Routine, lastStart: Date)] = []
|
||||
for workout in workouts {
|
||||
guard let routineID = workout.routineID else { continue }
|
||||
let liveID = sync.currentRoutineID(for: routineID)
|
||||
guard !seen.contains(liveID) else { continue }
|
||||
seen.insert(liveID)
|
||||
if let routine = routines.first(where: { $0.id == liveID }) {
|
||||
recents.append((routine, workout.start))
|
||||
if recents.count == 3 { break }
|
||||
}
|
||||
}
|
||||
return recents
|
||||
}
|
||||
|
||||
/// One selectable routine row, shared by the Recent and All Routines sections.
|
||||
/// Recent rows pass `lastTrained` to show how long ago the routine was run.
|
||||
private func routineRow(_ routine: Routine, lastTrained: Date? = nil) -> some View {
|
||||
Button {
|
||||
confirmAndStart(with: routine)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: routine.systemImage)
|
||||
.foregroundColor(Color.color(from: routine.color))
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(routine.name)
|
||||
if let lastTrained {
|
||||
Text(lastTrained.daysAgoLabel())
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text("\(routine.exercisesArray.count) exercises")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if !recentRoutines.isEmpty {
|
||||
Section("Recent") {
|
||||
ForEach(recentRoutines, id: \.routine.id) { recent in
|
||||
routineRow(recent.routine, lastTrained: recent.lastStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(recentRoutines.isEmpty ? "" : "All Routines") {
|
||||
ForEach(routines) { routine in
|
||||
routineRow(routine)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select a Routine")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
activePromptTitle,
|
||||
isPresented: Binding(
|
||||
get: { routineAwaitingConfirmation != nil },
|
||||
set: { if !$0 { routineAwaitingConfirmation = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: routineAwaitingConfirmation
|
||||
) { routine in
|
||||
Button("End Current & Start New") { endActiveThenStart(with: routine) }
|
||||
Button("Start in Parallel") { start(with: routine) }
|
||||
Button("Cancel", role: .cancel) { routineAwaitingConfirmation = nil }
|
||||
} message: { _ in
|
||||
Text(activePromptMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activePromptTitle: String {
|
||||
activeWorkouts.count == 1 ? "Workout in Progress" : "\(activeWorkouts.count) Workouts in Progress"
|
||||
}
|
||||
|
||||
private var activePromptMessage: String {
|
||||
let n = activeWorkouts.count
|
||||
let those = n == 1 ? "it" : "them"
|
||||
return "You already have \(n == 1 ? "a workout" : "\(n) workouts") going. End \(those) first, or run this one alongside."
|
||||
}
|
||||
|
||||
/// Prompt before starting if other workouts are still going; otherwise start straight away.
|
||||
private func confirmAndStart(with routine: Routine) {
|
||||
if activeWorkouts.isEmpty {
|
||||
start(with: routine)
|
||||
} else {
|
||||
routineAwaitingConfirmation = routine
|
||||
}
|
||||
}
|
||||
|
||||
/// End every in-flight workout (keeping its progress), then start the picked routine.
|
||||
private func endActiveThenStart(with routine: Routine) {
|
||||
// A pristine draft (no exercise ever started) has nothing worth keeping —
|
||||
// "ending" it would mint a junk all-skipped completed workout, so it's
|
||||
// discarded instead, same rule as backing out of one.
|
||||
let toDiscard = activeWorkouts.filter(\.isPristineDraft)
|
||||
let toEnd = activeWorkouts.filter { !$0.isPristineDraft }.map { WorkoutDocument(from: $0) }
|
||||
routineAwaitingConfirmation = nil
|
||||
Task {
|
||||
for workout in toDiscard {
|
||||
await sync.delete(workout: workout)
|
||||
}
|
||||
for var doc in toEnd {
|
||||
doc.endKeepingProgress()
|
||||
await sync.save(workout: doc)
|
||||
}
|
||||
}
|
||||
start(with: routine)
|
||||
}
|
||||
|
||||
private func start(with routine: Routine) {
|
||||
// Hand the id back after the save so the presenter can poll the cache and
|
||||
// navigate into the run — mirroring the exercise-list start path.
|
||||
Task {
|
||||
let id = await WorkoutStarter.start(routine: routine, sync: sync)
|
||||
onStarted(id)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// StartedWorkoutNavigator.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Started-Workout Navigation
|
||||
|
||||
/// Drives a programmatic push into a freshly-started workout's log screen. Both start
|
||||
/// paths — the routine picker sheet and the Today board — mint a `WorkoutDocument`,
|
||||
/// write it, then hand its id here; the workout row only materializes once the
|
||||
/// file→observer→cache loop round-trips, so we poll the cache for the entity and push it
|
||||
/// the moment it lands.
|
||||
private struct StartedWorkoutNavigator: ViewModifier {
|
||||
/// The pushed state is a plain value wrapping the run's id — never a retained
|
||||
/// `@Model`. This screen stays pushed for the whole workout, and the run's entity
|
||||
/// can be deleted and re-imported underneath it (observer remove/re-add churn, a
|
||||
/// remote delete); a `@Model` held in `@State` would be *invalidated* by then —
|
||||
/// deleted-and-saved models read `isDeleted == false` yet trap on any property
|
||||
/// access (the TestFlight 2.3 (125) "crashed when watch ended an exercise" report).
|
||||
private struct RunRoute: Identifiable, Hashable { let id: String }
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Binding var pendingWorkoutID: String?
|
||||
@State private var resolvedRun: RunRoute?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.navigationDestination(item: $resolvedRun) { route in
|
||||
// Re-fetch on every build so the destination always maps a live
|
||||
// entity; a re-imported run resolves to its fresh instance.
|
||||
if let workout = CacheMapper.fetchWorkout(id: route.id, in: modelContext) {
|
||||
WorkoutLogListView(workout: workout)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Workout Unavailable",
|
||||
systemImage: "xmark.circle",
|
||||
description: Text("This workout is no longer on this device."))
|
||||
}
|
||||
}
|
||||
.onChange(of: pendingWorkoutID) { _, id in
|
||||
guard let id else { return }
|
||||
pollForWorkout(id: id)
|
||||
}
|
||||
// Backing out of a run that never started an exercise discards it —
|
||||
// tapping into a routine to look around must not persist a phantom
|
||||
// workout. Anything with real progress is kept.
|
||||
.onChange(of: resolvedRun) { old, new in
|
||||
guard let old, new == nil else { return }
|
||||
discardIfPristine(id: old.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the just-popped workout if it's still an untouched draft. Fetches
|
||||
/// fresh by id — never a retained entity — and re-checks liveness.
|
||||
private func discardIfPristine(id: String) {
|
||||
guard let workout = CacheMapper.fetchWorkout(id: id, in: modelContext),
|
||||
!workout.isDeleted, workout.isPristineDraft else { return }
|
||||
Task { await sync.delete(workout: workout) }
|
||||
}
|
||||
|
||||
/// Poll for the entity after we write the document (the file→observer→cache loop
|
||||
/// typically completes in well under a second). Clear the pending id once it
|
||||
/// resolves, or silently after ~3 s if it never arrives.
|
||||
private func pollForWorkout(id: String) {
|
||||
Task {
|
||||
for _ in 0..<20 {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
if CacheMapper.fetchWorkout(id: id, in: modelContext) != nil {
|
||||
resolvedRun = RunRoute(id: id)
|
||||
pendingWorkoutID = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
pendingWorkoutID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Navigate into a workout's log screen once it appears in the cache after being
|
||||
/// started. Bind to the state you set to the new workout's id right after saving it.
|
||||
func navigatesToStartedWorkout(pendingWorkoutID: Binding<String?>) -> some View {
|
||||
modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// WorkoutStarter.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import Foundation
|
||||
|
||||
/// The one place a workout is created from a routine. Both start paths — the routine
|
||||
/// picker sheet and the Today board — call this so the plan-time log snapshot and
|
||||
/// the returned id stay identical per site.
|
||||
@MainActor
|
||||
enum WorkoutStarter {
|
||||
/// Builds and saves a fresh workout from a routine (plan-time log snapshot per
|
||||
/// exercise) and returns the new workout's id so the caller can navigate once the
|
||||
/// cache catches up. Deliberately does NOT touch the watch: the wrist session
|
||||
/// launches only when the first exercise actually starts (see
|
||||
/// `SyncEngine.onWorkoutBecameActive`), so a peek-then-back never spins one up.
|
||||
static func start(routine: Routine, sync: SyncEngine) async -> String {
|
||||
let startDate = Date()
|
||||
let logs = routine.exercisesArray.enumerated().map { index, exercise in
|
||||
WorkoutLogDocument(planFrom: ExerciseDocument(from: exercise), order: index, date: startDate)
|
||||
}
|
||||
|
||||
// A freshly started workout has no `end` — only completion stamps it.
|
||||
let doc = WorkoutDocument(
|
||||
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
routineID: routine.id,
|
||||
routineName: routine.name,
|
||||
start: startDate,
|
||||
end: nil,
|
||||
status: WorkoutStatus.notStarted.rawValue,
|
||||
createdAt: startDate,
|
||||
updatedAt: startDate,
|
||||
logs: logs,
|
||||
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance
|
||||
)
|
||||
|
||||
await sync.save(workout: doc)
|
||||
return doc.id
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user