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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user