Add machine comfort settings and tighten the document schemas

Machine-based exercises now carry ordered name/value comfort settings
(seat height, back-rest position, ...) on both ExerciseDocument and, as a
plan-time snapshot, WorkoutLogDocument — the optional array doubles as the
machine flag. Editable from the exercise editor's new Machine section and
from the workout row's settings sheet, whose mid-workout edits write back
to the originating split (workout saved first, so seed clone-on-edit
repointing can't clobber the log edit).

Schema tightening rides the same rev: splits bump to v2 (weight-reminder
fields and the unused exercise category removed), workouts to v3 (the
derived `completed` flag removed; status is the single source). Starter
seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema
bumped to rebuild. SCHEMA.md documents the shapes.

Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
This commit is contained in:
2026-07-06 16:29:44 -04:00
parent fce8fa4c17
commit 2c1e4759ae
33 changed files with 1132 additions and 586 deletions
+34 -51
View File
@@ -63,51 +63,46 @@ struct SplitDetailView: View {
.font(.caption)
}
// One section per category (warm-up first). A split with no warm-ups keeps
// the single plain "Exercises" section it always had.
// Headerless what the exercise list is needs no label; the section
// itself keeps the visual separation.
if split.exercisesArray.isEmpty {
Section(header: Text("Exercises")) {
Section {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
ListItem(title: "Add Exercise")
}
}
} else {
let grouped = groupedExercises(for: split)
ForEach(grouped, id: \.category) { group in
Section(header: Text(grouped.count == 1 ? "Exercises" : group.category.displayName)) {
ForEach(group.exercises) { 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(in: group.category, from: source, to: destination)
}
if group.category == grouped.last?.category {
Section {
ForEach(split.exercisesArray) { item in
ListItem(
title: item.name,
subtitle: item.planSummary(weightUnit: weightUnit)
)
.swipeActions {
Button {
showingExerciseAddSheet = true
itemToDelete = item
} label: {
ListItem(systemName: "plus.circle", title: "Add Exercise")
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")
}
}
}
}
@@ -155,26 +150,14 @@ struct SplitDetailView: View {
}
}
/// Exercises bucketed by category in display order, dropping empty buckets.
private func groupedExercises(for split: Split) -> [(category: ExerciseCategory, exercises: [Exercise])] {
let all = split.exercisesArray
return ExerciseCategory.displayOrder.compactMap { category in
let members = all.filter { $0.categoryEnum == category }
return members.isEmpty ? nil : (category, members)
}
}
/// Reorder within one category's section, then renumber globally with the
/// sections' display order (warm-ups first) as the canonical file order. Resolves
/// the current split at call time so it follows a clone-on-edit.
private func moveExercises(in category: ExerciseCategory, from source: IndexSet, to destination: Int) {
/// Reorder and renumber. Resolves the current split 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 groups = groupedExercises(for: split)
guard let gi = groups.firstIndex(where: { $0.category == category }) else { return }
groups[gi].exercises.move(fromOffsets: source, toOffset: destination)
var ordered = split.exercisesArray
ordered.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split)
let ordered = groups.flatMap(\.exercises)
doc.exercises = ordered.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
@@ -197,7 +180,7 @@ struct SplitDetailView: View {
id: ULID.make(), name: exName, order: base + i,
sets: 3, reps: 10, weight: 40,
loadType: LoadType.weight.rawValue,
durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2
durationSeconds: 0
)
}
doc.exercises.append(contentsOf: newDocs)
+51 -13
View File
@@ -10,12 +10,19 @@
import SwiftUI
import SwiftData
/// The Splits library screen, pushed from Settings Library: a two-column grid of
/// split tiles. Tapping a tile 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
@Environment(\.modelContext) private var modelContext
@Query(sort: \Split.order) private var splits: [Split]
@State private var showingAddSheet = false
@State private var splitToDelete: Split?
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
@@ -25,6 +32,13 @@ struct SplitListView: View {
} label: {
SplitItem(split: split)
}
.contextMenu {
Button(role: .destructive) {
splitToDelete = split
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.padding()
@@ -32,20 +46,44 @@ struct SplitListView: View {
.overlay {
if splits.isEmpty {
ContentUnavailableView(
label: {
Label("No Splits Yet", systemImage: "dumbbell.fill")
},
description: {
Text("Create a split to organize your workout routine.")
},
actions: {
Button("Add Starter Splits") {
Task { await SplitSeeder.seedDefaults(into: modelContext, using: sync) }
}
.buttonStyle(.borderedProminent)
}
"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.")
}
}
}
-31
View File
@@ -1,31 +0,0 @@
//
// SplitsView.swift
// Workouts
//
// Created by rzen on 7/17/25 at 6:55 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
struct SplitsView: View {
@State private var showingAddSheet: Bool = false
var body: some View {
NavigationStack {
SplitListView()
.navigationTitle("Splits")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { showingAddSheet.toggle() }) {
Image(systemName: "plus")
}
}
}
}
.sheet(isPresented: $showingAddSheet) {
SplitAddEditView(split: nil)
}
}
}