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
177 lines
6.7 KiB
Swift
177 lines
6.7 KiB
Swift
//
|
|
// 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()
|
|
}
|
|
}
|