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,10 @@
|
||||
//
|
||||
// OrderableItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 5:19 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
// No longer used — reordering is now a SyncEngine document write (onMove → doc save).
|
||||
// File kept to avoid Xcode project reference errors.
|
||||
@@ -0,0 +1,230 @@
|
||||
//
|
||||
// RoutineAddEditView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 9:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct RoutineAddEditView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// 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 `Routine`. `currentRoutineID` follows that swap. `routineID` is
|
||||
// nil in create mode.
|
||||
@State private var routineID: String?
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
@State private var name: String = ""
|
||||
@State private var color: String = "indigo"
|
||||
@State private var systemImage: String = "dumbbell.fill"
|
||||
@State private var activityType: WorkoutActivityType = .traditionalStrength
|
||||
@State private var autoAdvance: Bool = false
|
||||
@State private var restOverrideEnabled: Bool = false
|
||||
@State private var restSecondsValue: Int = 45
|
||||
@State private var showingIconPicker: Bool = false
|
||||
@State private var showingDeleteConfirmation: Bool = false
|
||||
|
||||
var isEditing: Bool { routineID != nil }
|
||||
|
||||
init(routine: Routine?, onDelete: (() -> Void)? = nil) {
|
||||
_routineID = State(initialValue: routine?.id)
|
||||
self.onDelete = onDelete
|
||||
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 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 && 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("Routine Unavailable", systemImage: "dumbbell")
|
||||
.task { dismiss() }
|
||||
} else {
|
||||
form(for: routine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func form(for routine: Routine?) -> some View {
|
||||
Form {
|
||||
Section(header: Text("Name")) {
|
||||
TextField("Name", text: $name)
|
||||
.bold()
|
||||
}
|
||||
|
||||
Section(header: Text("Appearance")) {
|
||||
Picker("Color", selection: $color) {
|
||||
ForEach(availableColors, id: \.self) { colorName in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color.color(from: colorName))
|
||||
.frame(width: 20, height: 20)
|
||||
Text(colorName.capitalized)
|
||||
}
|
||||
.tag(colorName)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
showingIconPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Icon")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Activity Type", selection: $activityType) {
|
||||
ForEach(WorkoutActivityType.allCases, id: \.self) { type in
|
||||
Label(type.displayName, systemImage: type.systemImage).tag(type)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Activity Type")
|
||||
} footer: {
|
||||
Text("Determines how this workout is categorized in Apple Health and how it credits your Activity rings.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Auto-Advance Exercises", isOn: $autoAdvance)
|
||||
|
||||
Toggle("Custom Rest Time", isOn: $restOverrideEnabled)
|
||||
if restOverrideEnabled {
|
||||
Stepper(value: $restSecondsValue, in: 10...180, step: 5) {
|
||||
HStack {
|
||||
Text("Rest Time")
|
||||
Spacer()
|
||||
Text("\(restSecondsValue)s").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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 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 routine = routine {
|
||||
Section(header: Text("Exercises")) {
|
||||
NavigationLink {
|
||||
ExerciseListView(routine: routine)
|
||||
} label: {
|
||||
ListItem(
|
||||
text: "Exercises",
|
||||
count: routine.exercisesArray.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Delete Routine", role: .destructive) {
|
||||
showingDeleteConfirmation = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing ? "Edit Routine" : "New Routine")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Save") {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingIconPicker) {
|
||||
SFSymbolPicker(selection: $systemImage)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete This Routine?",
|
||||
isPresented: $showingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let routine = routine {
|
||||
Task {
|
||||
await sync.delete(routine: routine)
|
||||
}
|
||||
dismiss()
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("This will permanently delete the routine and all its exercises.")
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if isEditing {
|
||||
// Update existing routine. If the id no longer resolves (deleted remotely
|
||||
// mid-edit), there's nothing to save.
|
||||
guard let routine = routine else { return }
|
||||
var doc = RoutineDocument(from: routine)
|
||||
doc.name = name
|
||||
doc.color = color
|
||||
doc.systemImage = systemImage
|
||||
doc.activityType = activityType.rawValue
|
||||
doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil
|
||||
doc.autoAdvance = autoAdvance ? true : nil
|
||||
doc.updatedAt = Date()
|
||||
Task { await sync.save(routine: doc) }
|
||||
} else {
|
||||
// Create new routine
|
||||
let existing = (try? modelContext.fetch(FetchDescriptor<Routine>())) ?? []
|
||||
let doc = RoutineDocument(
|
||||
schemaVersion: RoutineDocument.currentSchemaVersion,
|
||||
id: ULID.make(),
|
||||
name: name,
|
||||
color: color,
|
||||
systemImage: systemImage,
|
||||
order: existing.count,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date(),
|
||||
exercises: [],
|
||||
activityType: activityType.rawValue,
|
||||
restSeconds: restOverrideEnabled ? restSecondsValue : nil,
|
||||
autoAdvance: autoAdvance ? true : nil
|
||||
)
|
||||
Task { await sync.save(routine: doc) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//
|
||||
// RoutineDetailView.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/25/25 at 3:27 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import IndieSync
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct RoutineDetailView: View {
|
||||
@Environment(SyncEngine.self) private var sync
|
||||
@Environment(AppServices.self) private var services
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// 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 `Routine`. `currentRoutineID` follows that swap.
|
||||
@State private var routineID: String
|
||||
@Query private var routines: [Routine]
|
||||
|
||||
@State private var showingExerciseAddSheet: 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(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 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 routine, so
|
||||
// `body` shows the "Routine Unavailable" state and dismisses; the row is on its
|
||||
// way out anyway.
|
||||
let live = !routine.isDeleted && routine.modelContext != nil
|
||||
_routineID = State(initialValue: live ? routine.id : "")
|
||||
}
|
||||
|
||||
private var routine: Routine? {
|
||||
let id = sync.currentRoutineID(for: routineID)
|
||||
return routines.first { $0.id == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let routine {
|
||||
content(for: routine)
|
||||
} else {
|
||||
// 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("Routine Unavailable", systemImage: "dumbbell")
|
||||
.task { dismiss() }
|
||||
}
|
||||
}
|
||||
// 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.setEditingRoutine(sync.currentRoutineID(for: routineID)) }
|
||||
.onDisappear { services.watchBridge.setEditingRoutine(nil) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func content(for routine: Routine) -> some View {
|
||||
Form {
|
||||
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 routine.exercisesArray.isEmpty {
|
||||
Section {
|
||||
Text("No exercises added yet.")
|
||||
Button(action: { showingExerciseAddSheet.toggle() }) {
|
||||
ListItem(title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(routine.exercisesArray) { item in
|
||||
ListItem(
|
||||
title: item.name,
|
||||
subtitle: item.planSummary(weightUnit: weightUnit)
|
||||
)
|
||||
.swipeActions {
|
||||
Button {
|
||||
itemToDelete = item
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
Button {
|
||||
itemToEdit = item
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.indigo)
|
||||
}
|
||||
}
|
||||
.onMove { source, destination in
|
||||
moveExercises(from: source, to: destination)
|
||||
}
|
||||
|
||||
Button {
|
||||
showingExerciseAddSheet = true
|
||||
} label: {
|
||||
ListItem(systemName: "plus.circle", title: "Add Exercise")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(routine.name)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showingRoutineEditSheet = true
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingExerciseAddSheet) {
|
||||
ExercisePickerView(onExerciseSelected: { exerciseNames in
|
||||
addExercises(names: exerciseNames)
|
||||
}, allowMultiSelect: true)
|
||||
}
|
||||
.sheet(isPresented: $showingRoutineEditSheet) {
|
||||
RoutineAddEditView(routine: routine) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.sheet(item: $itemToEdit) { item in
|
||||
ExerciseAddEditView(exercise: item, routine: routine)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Exercise?",
|
||||
isPresented: Binding(
|
||||
get: { itemToDelete != nil },
|
||||
set: { if !$0 { itemToDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: itemToDelete
|
||||
) { item in
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteExercise(item)
|
||||
itemToDelete = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
itemToDelete = nil
|
||||
}
|
||||
} message: { item in
|
||||
Text("Remove \"\(item.name)\" from this routine?")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 routine else { return }
|
||||
var ordered = routine.exercisesArray
|
||||
ordered.move(fromOffsets: source, toOffset: destination)
|
||||
|
||||
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(routine: doc) }
|
||||
}
|
||||
|
||||
private func addExercises(names: [String]) {
|
||||
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
|
||||
.filter { !existingNames.contains($0) }
|
||||
.enumerated()
|
||||
.map { i, exName -> ExerciseDocument in
|
||||
// Seed from the library's authored `**Defaults:**` when available,
|
||||
// falling back to a plain 3×10 weighted guess; weight always starts
|
||||
// at 0 (there's no prior lift to seed it from).
|
||||
let defaults = ExerciseInfoLibrary.info(for: exName)?.defaults
|
||||
return ExerciseDocument(
|
||||
id: ULID.make(), name: exName, order: base + i,
|
||||
sets: defaults?.sets ?? 3, reps: defaults?.reps ?? 10, weight: 0,
|
||||
loadType: (defaults?.loadType ?? .weight).rawValue,
|
||||
durationSeconds: defaults?.durationSeconds ?? 0
|
||||
)
|
||||
}
|
||||
doc.exercises.append(contentsOf: newDocs)
|
||||
doc.updatedAt = Date()
|
||||
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 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(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//
|
||||
// SortableForEach.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 2:04 PM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
// No longer used — list reordering is handled with SwiftUI's native onMove modifier
|
||||
// backed by SyncEngine document writes. File kept to avoid Xcode project reference errors.
|
||||
Reference in New Issue
Block a user