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:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
+12
View File
@@ -43,6 +43,18 @@ final class AppServices {
let liveRunState = LiveRunState()
self.liveRunState = liveRunState
self.watchBridge = PhoneConnectivityBridge(container: container, syncEngine: syncEngine, liveRunState: liveRunState)
// Launch the wrist session only when a run actually begins (the first
// exercise leaves `.notStarted` on the phone) creating a workout alone is
// just a peek and must not start anything on the watch. Watch-originated
// starts don't fire this hook; the watch already owns its session.
syncEngine.onWorkoutBecameActive = { [weak self] doc in
guard let self else { return }
let routine = doc.routineID.flatMap { id in
CacheMapper.fetchRoutine(id: syncEngine.currentRoutineID(for: id), in: container.mainContext)
}
let activityType = routine?.activityTypeEnum ?? .traditionalStrength
self.workoutLauncher.launchWatchWorkout(activityType: activityType.hkActivityType)
}
#if DEBUG
if ScreenshotSeed.isActive { ScreenshotSeed.populate(container.mainContext) }
#endif
@@ -5,7 +5,7 @@ import WatchConnectivity
/// Phone side of the iPhoneWatch bridge. The phone owns iCloud Drive; the watch
/// is a thin remote that round-trips through it:
/// Phone Watch: pushes all splits + recent workouts as the latest
/// Phone Watch: pushes all routines + recent workouts as the latest
/// application context whenever the cache changes (local or remote).
/// Watch Phone: receives an updated `WorkoutDocument` and applies it via the
/// SyncEngine write path (file observer cache push back).
@@ -19,12 +19,12 @@ final class PhoneConnectivityBridge: NSObject {
private var session: WCSession?
/// Exclusive-edit lock published to the watch. While the phone has a workout's
/// exercise (or a split) open in an editor, the watch parks any matching run and
/// exercise (or a routine) open in an editor, the watch parks any matching run and
/// blocks re-entry so the two devices never drive the same run at once. Included in
/// every `pushAll` (the latest-wins context replaces wholesale, so a push that omitted
/// them would clear the lock prematurely).
private(set) var editingWorkoutID: String?
private(set) var editingSplitID: String?
private(set) var editingRoutineID: String?
/// While true (scene backgrounded), the edit locks are *published* to the watch as
/// cleared without being forgotten locally. An editor left open in a pocketed or
@@ -70,7 +70,7 @@ final class PhoneConnectivityBridge: NSObject {
syncEngine.onCacheChanged = { [weak self] in self?.pushAll() }
}
/// Sends the current splits + most-recent workouts to the watch.
/// Sends the current routines + most-recent workouts to the watch.
func pushAll() {
guard let session, session.activationState == .activated, session.isPaired,
session.isWatchAppInstalled else {
@@ -78,7 +78,7 @@ final class PhoneConnectivityBridge: NSObject {
return
}
let splits = (try? context.fetch(FetchDescriptor<Split>(sortBy: [SortDescriptor(\.order)]))) ?? []
let routines = (try? context.fetch(FetchDescriptor<Routine>(sortBy: [SortDescriptor(\.order)]))) ?? []
// The watch only needs what it can act on: every active run (in-progress /
// not-started) plus recently-completed ones, kept ~24h so a run that just
@@ -107,24 +107,24 @@ final class PhoneConnectivityBridge: NSObject {
let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45
let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5
let weightUnit = UserDefaults.standard.string(forKey: WCPayload.weightUnitKey) ?? WeightUnit.lb.rawValue
let splitDocs = splits.map(SplitDocument.init(from:))
let routineDocs = routines.map(RoutineDocument.init(from:))
func encode(_ included: [Workout]) -> [String: Any] {
WCPayload.encodeState(
splits: splitDocs,
routines: routineDocs,
workouts: included.map(WorkoutDocument.init(from:)),
restSeconds: restSeconds,
doneCountdownSeconds: doneCountdownSeconds,
weightUnit: weightUnit,
editingWorkoutID: locksSuspended ? nil : editingWorkoutID,
editingSplitID: locksSuspended ? nil : editingSplitID
editingRoutineID: locksSuspended ? nil : editingRoutineID
)
}
do {
let payload = encode(workouts)
try session.updateApplicationContext(payload)
let bytes = ((payload[WCPayload.splitsKey] as? Data)?.count ?? 0)
let bytes = ((payload[WCPayload.routinesKey] as? Data)?.count ?? 0)
+ ((payload[WCPayload.workoutsKey] as? Data)?.count ?? 0)
Self.log.info("pushAll: \(splits.count) splits, \(workouts.count) workouts (\(bytes) bytes)")
Self.log.info("pushAll: \(routines.count) routines, \(workouts.count) workouts (\(bytes) bytes)")
} catch {
// Realistically payload-too-large. The context is the watch's lifeline a
// dropped push means it silently never hears about this state again so
@@ -145,7 +145,7 @@ final class PhoneConnectivityBridge: NSObject {
func setLocksSuspended(_ suspended: Bool) {
guard locksSuspended != suspended else { return }
locksSuspended = suspended
if editingWorkoutID != nil || editingSplitID != nil { pushAll() }
if editingWorkoutID != nil || editingRoutineID != nil { pushAll() }
}
/// Mark (or clear, with `nil`) the workout currently open in a phone exercise editor.
@@ -157,11 +157,11 @@ final class PhoneConnectivityBridge: NSObject {
pushAll()
}
/// Mark (or clear, with `nil`) the split currently open in a phone editor. The watch
/// parks any active run sourced from that split (matched by `splitID`).
func setEditingSplit(_ id: String?) {
guard editingSplitID != id else { return }
editingSplitID = id
/// Mark (or clear, with `nil`) the routine currently open in a phone editor. The watch
/// parks any active run sourced from that routine (matched by `routineID`).
func setEditingRoutine(_ id: String?) {
guard editingRoutineID != id else { return }
editingRoutineID = id
pushAll()
}
+24 -14
View File
@@ -11,20 +11,30 @@ struct ContentView: View {
@Environment(LiveRunState.self) private var liveRun
var body: some View {
WorkoutLogsView()
// Prop the phone up and it runs the live Apple Watch workout two-way: drive from
// either device and the other follows.
.fullScreenCover(isPresented: Binding(
get: { liveRun.presentable != nil },
set: { presenting in if !presenting { liveRun.mute() } }
)) {
if let frame = liveRun.presentable {
// Re-key on the followed log so a flow-mode auto-advance (the driver
// moving to the next exercise) rebuilds the mirror for the new exercise
// instead of leaving a stale one pinned to the previous log.
LiveRunCoverView(frame: frame) { liveRun.mute() }
.id(frame.logID)
}
TabView {
Tab("Today", systemImage: "sun.max.fill") {
TodayView()
}
Tab("Progress", systemImage: "chart.line.uptrend.xyaxis") {
ProgressTabView()
}
Tab("Settings", systemImage: "gearshape.2") {
SettingsView()
}
}
// Prop the phone up and it runs the live Apple Watch workout two-way: drive from
// either device and the other follows.
.fullScreenCover(isPresented: Binding(
get: { liveRun.presentable != nil },
set: { presenting in if !presenting { liveRun.mute() } }
)) {
if let frame = liveRun.presentable {
// Re-key on the followed log so a flow-mode auto-advance (the driver
// moving to the next exercise) rebuilds the mirror for the new exercise
// instead of leaving a stale one pinned to the previous log.
LiveRunCoverView(frame: frame) { liveRun.mute() }
.id(frame.logID)
}
}
}
}
+4 -3
View File
@@ -19,7 +19,7 @@ final class DiagnosticsViewModel {
/// `generate()` runs) and each of its pieces tears itself down after each call.
/// The schema scanner resolves the forward-gate ceiling per file, because
/// Workouts has two document types with independent version counters a
/// `Splits/` file is gated by `SplitDocument.currentSchemaVersion`, a
/// `Splits/` file is gated by `RoutineDocument.currentSchemaVersion`, a
/// `Workouts/` file by `WorkoutDocument.currentSchemaVersion`, and a `Stubs/`
/// tombstone carries no versioned payload so it's never counted as skipped.
private let coordinator = DiagnosticsCoordinator(
@@ -27,12 +27,13 @@ final class DiagnosticsViewModel {
schemaScanner: SchemaSkipScanner(
ceilingForURL: { url in
let path = url.path
if path.contains("/Splits/") { return SplitDocument.currentSchemaVersion }
// PINNED: routine documents live under the on-disk "Splits/" directory.
if path.contains("/Splits/") { return RoutineDocument.currentSchemaVersion }
if path.contains("/Workouts/") { return WorkoutDocument.currentSchemaVersion }
return .max
},
representativeCeiling: max(
SplitDocument.currentSchemaVersion,
RoutineDocument.currentSchemaVersion,
WorkoutDocument.currentSchemaVersion
)
)
+2 -2
View File
@@ -12,9 +12,9 @@
//
// ADAPTATION FROM THE REFERENCE: the reference scanner takes a single
// `currentSchemaVersion: Int`. Workouts has TWO document types with
// INDEPENDENT version counters (`SplitDocument.currentSchemaVersion` and
// INDEPENDENT version counters (`RoutineDocument.currentSchemaVersion` and
// `WorkoutDocument.currentSchemaVersion`), so one global ceiling can't classify
// both correctly a Split written at a version the workout counter has already
// both correctly a Routine written at a version the workout counter has already
// reached would be misjudged. The forward-gate ceiling therefore has to be
// resolved per file (its location under `Documents/` tells us its type), so the
// scanner takes a `CeilingResolver` closure. A single-Int convenience init is
+1 -1
View File
@@ -22,7 +22,7 @@ struct ExerciseInfo {
var targets: [String]
var sections: [Section]
/// Parsed `**Defaults:**` bullet the library's suggested starting plan for an
/// exercise added outside a split (no prior log, no split entry to copy from).
/// exercise added outside a routine (no prior log, no routine entry to copy from).
/// `nil` when the bullet is absent (not yet authored) or unparseable.
var defaults: Defaults?
+1 -1
View File
@@ -19,7 +19,7 @@ final class WorkoutLauncher {
private let healthStore = HKHealthStore()
/// Configuration handed to watchOS; the watch starts a session with the same
/// shape (see `WorkoutSessionManager`). The activity type comes from the split so
/// shape (see `WorkoutSessionManager`). The activity type comes from the routine so
/// the saved Health workout is categorized correctly.
static func makeConfiguration(activityType: HKWorkoutActivityType) -> HKWorkoutConfiguration {
let configuration = HKWorkoutConfiguration()
+557
View File
@@ -0,0 +1,557 @@
//
// ProgressPlanner.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import Foundation
// The Progress tab's derivation engine a pure, unit-testable planner (precedent:
// `SeedReconcilePlanner`). Everything here is a deterministic function of plain value
// snapshots: per-schedule weekly adherence, per-goal rollups with streaks, trend
// buckets for the charts, and the achievements grid. Nothing is ever persisted
// streaks and badges are recomputed from workout documents on every read, so they
// survive cache rebuilds by construction (see UX-REDESIGN.md).
// MARK: - Inputs
/// A plain snapshot of a `Schedule` cache entity. `routineID` must already be
/// live-resolved through `SyncEngine.currentRoutineID(for:)` so seed clone-on-edit
/// redirects don't orphan history.
struct ScheduleFacts: Sendable, Equatable {
var id: String
var goal: GoalKind?
var recurrence: ScheduleRecurrence
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) 7 (Sat); .fixedDays only
var timesPerWeek: Int? // .frequency only
var onceDate: Date? // .once only
var createdAt: Date
var routineID: String // live-resolved
var routineName: String
}
/// A plain snapshot of a `Workout` cache entity. `routineID` live-resolved like
/// `ScheduleFacts.routineID`. `volume` is the caller's best value (recorded metric,
/// else derived from set entries); `activeMinutes` is the completed duration.
struct WorkoutFacts: Sendable, Equatable {
var routineID: String?
var start: Date
var end: Date?
var completed: Bool
var volume: Double
var activeMinutes: Double
var energyKcal: Double
}
// MARK: - Adherence outputs
/// One schedule-week (or goal-week) verdict: how many required units were met.
/// `expected == 0` marks a *vacuous* week the schedule imposed nothing (created
/// mid-week past its fixed days, a one-off in another week); vacuous weeks neither
/// break nor extend streaks.
struct WeekMark: Sendable, Equatable {
var weekStart: Date
var met: Int
var expected: Int
var hit: Bool { met >= expected }
var isVacuous: Bool { expected == 0 }
/// Fill fraction for the week-strip squares (01).
var fraction: Double {
expected > 0 ? min(1, Double(met) / Double(expected)) : 0
}
}
/// One schedule's adherence across the track's week grid. `weeks` aligns 1:1 with the
/// grid (oldest newest); `nil` = the schedule didn't exist / doesn't apply that week.
struct ScheduleTrack: Sendable, Equatable {
var schedule: ScheduleFacts
var weeks: [WeekMark?]
/// The current week can no longer be met (a daily's missed day, too few days left
/// for the remaining frequency count). Distinct from "not yet hit".
var currentWeekBroken: Bool
var currentWeek: WeekMark? { weeks.last ?? nil }
}
/// A goal's rolled-up adherence: per-week sums across member schedules, plus the
/// streak. The goal is the ledger category its track continues unbroken across
/// schedule rewrites (UX-REDESIGN.md).
struct GoalTrack: Sendable, Equatable, Identifiable {
var goal: GoalKind? // nil = the "Unassigned" group
var scheduleTracks: [ScheduleTrack]
var weeks: [WeekMark] // rollup, one per grid week
/// Consecutive fully-hit weeks ending now. The current week counts once hit;
/// merely "not yet hit but still possible" neither counts nor breaks.
var streakWeeks: Int
/// False when any member schedule's current week is already unmeetable.
var streakAlive: Bool
var id: String { goal?.rawValue ?? "unassigned" }
}
// MARK: - Trend outputs
/// Chart time windows, per the trend-visualization pattern (7D/30D/90D/1Y/All).
enum TrendRange: String, CaseIterable, Identifiable, Sendable {
case week = "7D"
case month = "30D"
case quarter = "90D"
case year = "1Y"
case all = "All"
var id: String { rawValue }
func startDate(now: Date, calendar: Calendar) -> Date? {
switch self {
case .week: calendar.date(byAdding: .day, value: -7, to: now)
case .month: calendar.date(byAdding: .day, value: -30, to: now)
case .quarter: calendar.date(byAdding: .day, value: -90, to: now)
case .year: calendar.date(byAdding: .year, value: -1, to: now)
case .all: nil
}
}
/// Bucket granularity: short ranges read daily, a quarter weekly, longer monthly.
var bucket: Calendar.Component {
switch self {
case .week, .month: .day
case .quarter: .weekOfYear
case .year, .all: .month
}
}
}
/// One chart bucket (a day, week, or month depending on the range).
struct TrendPoint: Sendable, Equatable, Identifiable {
var date: Date // bucket start
var workouts: Int
var volume: Double
var activeMinutes: Double
var energyKcal: Double
var id: Date { date }
}
// MARK: - Achievements
/// A derived badge. `progress` is 01 toward earning (1 when earned) so locked badges
/// can show how close they are.
struct Achievement: Sendable, Equatable, Identifiable {
var id: String
var title: String
var detail: String
var systemImage: String
var colorName: String
var earned: Bool
var progress: Double
}
// MARK: - Planner
enum ProgressPlanner {
// MARK: Goal tracks
/// Roll schedules + workouts up into per-goal adherence tracks over the last
/// `weeksBack` calendar weeks (oldest newest, ending at the current week).
/// Groups appear in `goalOrder`; schedules keep their input order within a group;
/// goal-less schedules land in a trailing `goal == nil` group. Goals with no
/// schedules produce no track.
static func goalTracks(
schedules: [ScheduleFacts],
workouts: [WorkoutFacts],
weeksBack: Int = 12,
goalOrder: [GoalKind] = GoalKind.allCases,
calendar: Calendar = .current,
now: Date = Date()
) -> [GoalTrack] {
let doneDays = completedDays(workouts: workouts, calendar: calendar)
let grid = weekGrid(weeksBack: weeksBack, calendar: calendar, now: now)
var groups: [(GoalKind?, [ScheduleFacts])] = goalOrder.map { kind in
(kind, schedules.filter { $0.goal == kind })
}
groups.append((nil, schedules.filter { $0.goal == nil }))
return groups.compactMap { goal, members in
guard !members.isEmpty else { return nil }
let tracks = members.map { schedule in
scheduleTrack(schedule: schedule, doneDays: doneDays[schedule.routineID] ?? [],
grid: grid, calendar: calendar, now: now)
}
let rollup = grid.enumerated().map { index, weekStart in
let marks = tracks.compactMap { $0.weeks[index] }
return WeekMark(weekStart: weekStart,
met: marks.reduce(0) { $0 + $1.met },
expected: marks.reduce(0) { $0 + $1.expected })
}
let alive = !tracks.contains { $0.currentWeekBroken }
return GoalTrack(goal: goal,
scheduleTracks: tracks,
weeks: rollup,
streakWeeks: streak(weeks: rollup, alive: alive),
streakAlive: alive)
}
}
/// One schedule's marks across the grid plus its current-week brokenness.
static func scheduleTrack(
schedule: ScheduleFacts,
doneDays: Set<Date>,
grid: [Date],
calendar: Calendar,
now: Date
) -> ScheduleTrack {
let weeks = grid.map { weekStart in
weekMark(schedule: schedule, doneDays: doneDays, weekStart: weekStart,
calendar: calendar, now: now)
}
let broken = grid.last.map { currentWeekStart in
currentWeekBroken(schedule: schedule, doneDays: doneDays,
weekStart: currentWeekStart, calendar: calendar, now: now)
} ?? false
return ScheduleTrack(schedule: schedule, weeks: weeks, currentWeekBroken: broken)
}
/// The verdict for one schedule-week, or nil when the schedule doesn't apply
/// (created after the week, a one-off in a different week, a future week).
///
/// Expectation semantics differ by recurrence so the *current* week reads as
/// "on track so far" rather than "not done yet":
/// - `.daily` / `.fixedDays`: expected counts only required days **up to today**;
/// met counts distinct completed days in that same window (so a Tuesday make-up
/// for a missed fixed Monday still counts catch-ups are honored).
/// - `.frequency`: expected is the full weekly target (clamped to the days the
/// schedule existed that week); mid-week it reads "1 of 2" until actually met.
/// - `.once`: expected 1, only in the week containing its date.
static func weekMark(
schedule: ScheduleFacts,
doneDays: Set<Date>,
weekStart: Date,
calendar: Calendar,
now: Date
) -> WeekMark? {
guard let window = window(for: schedule, weekStart: weekStart,
calendar: calendar, clampToDay: calendar.startOfDay(for: now))
else { return nil }
let done = window.elapsedDays.filter(doneDays.contains).count
switch schedule.recurrence {
case .daily:
return WeekMark(weekStart: weekStart, met: done, expected: window.elapsedDays.count)
case .fixedDays:
let wanted = Set(schedule.weekdays ?? [])
let required = window.elapsedDays
.filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
return WeekMark(weekStart: weekStart, met: min(done, required), expected: required)
case .frequency:
let target = min(max(schedule.timesPerWeek ?? 0, 0), window.fullDays.count)
return WeekMark(weekStart: weekStart, met: min(done, target), expected: target)
case .once:
guard let once = schedule.onceDate,
calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
else { return nil }
return WeekMark(weekStart: weekStart, met: min(done, 1), expected: 1)
}
}
/// True when the current week can no longer be met: the days remaining (today
/// included) are fewer than the requirement still outstanding. A daily's missed
/// yesterday breaks immediately; a 2×/week with one done and two days left doesn't.
static func currentWeekBroken(
schedule: ScheduleFacts,
doneDays: Set<Date>,
weekStart: Date,
calendar: Calendar,
now: Date
) -> Bool {
let today = calendar.startOfDay(for: now)
guard let window = window(for: schedule, weekStart: weekStart,
calendar: calendar, clampToDay: today)
else { return false }
let done = window.fullDays.filter(doneDays.contains).count
let daysLeft = window.fullDays.filter { $0 >= today }.count
let fullRequirement: Int
switch schedule.recurrence {
case .daily:
fullRequirement = window.fullDays.count
case .fixedDays:
let wanted = Set(schedule.weekdays ?? [])
fullRequirement = window.fullDays
.filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count
case .frequency:
fullRequirement = min(max(schedule.timesPerWeek ?? 0, 0), window.fullDays.count)
case .once:
guard let once = schedule.onceDate,
calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart
else { return false }
fullRequirement = 1
}
return fullRequirement - done > daysLeft
}
// MARK: Trends
/// Completed workouts bucketed for the chart: daily for 7D/30D, weekly for 90D,
/// monthly for 1Y/All. Empty buckets are zero-filled from the range start (All:
/// from the first workout) through now, so gaps read as gaps rather than the line
/// skipping them. Empty input (or an empty window) returns [].
static func trendPoints(
workouts: [WorkoutFacts],
range: TrendRange,
calendar: Calendar = .current,
now: Date = Date()
) -> [TrendPoint] {
let cutoff = range.startDate(now: now, calendar: calendar)
let completed = workouts.filter { workout in
workout.completed && (cutoff.map { workout.start >= $0 } ?? true)
}
func bucketStart(_ date: Date) -> Date {
calendar.dateInterval(of: range.bucket, for: date)?.start ?? date
}
var byBucket: [Date: TrendPoint] = [:]
for workout in completed {
let key = bucketStart(workout.start)
var point = byBucket[key] ?? TrendPoint(date: key, workouts: 0, volume: 0,
activeMinutes: 0, energyKcal: 0)
point.workouts += 1
point.volume += workout.volume
point.activeMinutes += workout.activeMinutes
point.energyKcal += workout.energyKcal
byBucket[key] = point
}
// Zero-fill from the window start so every bucket exists.
let firstDate = cutoff ?? completed.map(\.start).min()
guard let firstDate else { return [] }
var points: [TrendPoint] = []
var cursor = bucketStart(firstDate)
let last = bucketStart(now)
while cursor <= last {
points.append(byBucket[cursor]
?? TrendPoint(date: cursor, workouts: 0, volume: 0, activeMinutes: 0, energyKcal: 0))
guard let next = calendar.date(byAdding: range.bucket, value: 1, to: cursor),
next > cursor else { break }
cursor = next
}
return points
}
// MARK: Achievements
/// The full badge set, earned and locked alike every one a pure derivation of
/// history (recomputable after any cache rebuild). Streak badges read the goal
/// tracks' best run, so their reach is capped by the tracks' `weeksBack`.
static func achievements(
tracks: [GoalTrack],
workouts: [WorkoutFacts],
calendar: Calendar = .current,
now: Date = Date()
) -> [Achievement] {
let completed = workouts.filter(\.completed)
let count = completed.count
let totalVolume = completed.reduce(0) { $0 + $1.volume }
var badges: [Achievement] = []
// Session-count milestones.
let countMilestones: [(Int, String, String)] = [
(1, "First Rep", "figure.walk"),
(10, "Ten Strong", "10.circle.fill"),
(50, "Fifty Club", "50.circle.fill"),
(100, "Century", "medal.fill"),
(250, "Relentless", "flame.circle.fill"),
]
for (target, title, symbol) in countMilestones {
badges.append(Achievement(
id: "count-\(target)", title: title,
detail: "Complete \(target) workout\(target == 1 ? "" : "s")",
systemImage: symbol, colorName: "blue",
earned: count >= target,
progress: min(1, Double(count) / Double(target))))
}
// Weekly-streak milestones best consecutive-hit run across any goal track.
let bestRun = tracks.map { bestHitRun(weeks: $0.weeks) }.max() ?? 0
let streakMilestones: [(Int, String)] = [
(4, "4-Week Rhythm"), (8, "8-Week Habit"), (12, "12-Week Lifestyle"),
]
for (target, title) in streakMilestones {
badges.append(Achievement(
id: "streak-\(target)", title: title,
detail: "Hit a goal every week for \(target) weeks straight",
systemImage: "flame.fill", colorName: "orange",
earned: bestRun >= target,
progress: min(1, Double(bestRun) / Double(target))))
}
// Consecutive active days.
let dayRun = longestDayRun(workouts: completed, calendar: calendar)
badges.append(Achievement(
id: "days-7", title: "Seven Straight",
detail: "Work out 7 days in a row",
systemImage: "calendar", colorName: "green",
earned: dayRun >= 7,
progress: min(1, Double(dayRun) / 7)))
// Lifetime volume.
let volumeMilestones: [(Double, String, String)] = [
(100_000, "Heavy Lifter", "scalemass.fill"),
(1_000_000, "Million Mover", "trophy.fill"),
]
for (target, title, symbol) in volumeMilestones {
badges.append(Achievement(
id: "volume-\(Int(target))", title: title,
detail: "Lift \(Int(target).formatted()) total volume",
systemImage: symbol, colorName: "purple",
earned: totalVolume >= target,
progress: min(1, totalVolume / target)))
}
// Time-of-day one-offs.
let hours = completed.map { calendar.component(.hour, from: $0.start) }
badges.append(Achievement(
id: "early-bird", title: "Early Bird",
detail: "Start a workout before 7 AM",
systemImage: "sunrise.fill", colorName: "yellow",
earned: hours.contains { $0 < 7 },
progress: hours.contains { $0 < 7 } ? 1 : 0))
badges.append(Achievement(
id: "night-owl", title: "Night Owl",
detail: "Start a workout after 9 PM",
systemImage: "moon.stars.fill", colorName: "indigo",
earned: hours.contains { $0 >= 21 },
progress: hours.contains { $0 >= 21 } ? 1 : 0))
// Clean sweep: some week in which every goal's requirement was fully hit.
let sweep = cleanSweepExists(tracks: tracks)
badges.append(Achievement(
id: "clean-sweep", title: "Clean Sweep",
detail: "Hit every goal in a single week",
systemImage: "checkmark.seal.fill", colorName: "teal",
earned: sweep, progress: sweep ? 1 : 0))
return badges
}
// MARK: - Shared helpers
/// Start-of-day set of every day with a completed workout, keyed by routine ID.
static func completedDays(workouts: [WorkoutFacts], calendar: Calendar) -> [String: Set<Date>] {
var result: [String: Set<Date>] = [:]
for workout in workouts where workout.completed {
guard let routineID = workout.routineID else { continue }
result[routineID, default: []].insert(calendar.startOfDay(for: workout.start))
}
return result
}
/// Week starts oldest newest, ending at the week containing `now`.
static func weekGrid(weeksBack: Int, calendar: Calendar, now: Date) -> [Date] {
guard weeksBack > 0,
let currentStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start
else { return [] }
return (0..<weeksBack).reversed().compactMap {
calendar.date(byAdding: .weekOfYear, value: -$0, to: currentStart)
}
}
/// The days of one schedule-week: `fullDays` is the whole week clipped to the
/// schedule's creation day; `elapsedDays` additionally clips to `clampToDay`
/// (today), for "so far" expectations. Nil when the schedule postdates the week.
private static func window(
for schedule: ScheduleFacts,
weekStart: Date,
calendar: Calendar,
clampToDay: Date
) -> (fullDays: [Date], elapsedDays: [Date])? {
guard let weekEnd = calendar.date(byAdding: .weekOfYear, value: 1, to: weekStart)
else { return nil }
let createdDay = calendar.startOfDay(for: schedule.createdAt)
let start = max(weekStart, createdDay)
guard start < weekEnd, start <= clampToDay else { return nil }
var fullDays: [Date] = []
var cursor = start
while cursor < weekEnd {
fullDays.append(cursor)
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor), next > cursor
else { break }
cursor = next
}
let elapsedDays = fullDays.filter { $0 <= clampToDay }
return (fullDays, elapsedDays)
}
/// Streak ending now: consecutive hit weeks scanned newest oldest. A vacuous
/// week is transparent (skipped); the current week counts once hit, is skipped
/// while still meetable, and zeroes the streak when already broken.
static func streak(weeks: [WeekMark], alive: Bool) -> Int {
guard !weeks.isEmpty else { return 0 }
var remaining = weeks
let current = remaining.removeLast()
var count = 0
if !current.isVacuous {
if current.hit { count += 1 }
else if !alive { return 0 }
// else: still in play neither counts nor breaks.
}
for week in remaining.reversed() {
if week.isVacuous { continue }
if week.hit { count += 1 } else { break }
}
return count
}
/// Longest run of consecutive hit weeks anywhere in the track (vacuous weeks are
/// transparent here too).
static func bestHitRun(weeks: [WeekMark]) -> Int {
var best = 0, run = 0
for week in weeks {
if week.isVacuous { continue }
if week.hit { run += 1; best = max(best, run) } else { run = 0 }
}
return best
}
/// Longest run of consecutive calendar days with at least one completed workout.
static func longestDayRun(workouts: [WorkoutFacts], calendar: Calendar) -> Int {
let days = Set(workouts.filter(\.completed).map { calendar.startOfDay(for: $0.start) })
.sorted()
var best = 0, run = 0
var previous: Date?
for day in days {
if let previous,
let next = calendar.date(byAdding: .day, value: 1, to: previous),
calendar.isDate(next, inSameDayAs: day) {
run += 1
} else {
run = 1
}
best = max(best, run)
previous = day
}
return best
}
/// True when some grid week saw every goal track's requirement fully hit
/// non-vacuously for at least two goals (a single-goal "sweep" is just a hit week).
static func cleanSweepExists(tracks: [GoalTrack]) -> Bool {
guard let weekCount = tracks.first?.weeks.count, !tracks.isEmpty else { return false }
for index in 0..<weekCount {
let marks = tracks.compactMap { $0.weeks.indices.contains(index) ? $0.weeks[index] : nil }
let active = marks.filter { !$0.isVacuous }
if active.count >= 2 && marks.allSatisfy(\.hit) { return true }
}
return false
}
}
@@ -0,0 +1,41 @@
# Meditation
Sit tall with the legs crossed, rest the hands on the knees, and follow the
breath. A timed stillness practice that lowers stress, settles the nervous
system, and closes a session — or a day — with focus.
- **Category:** Mindfulness
- **Type:** Seated meditation (timed hold)
- **Targets:** Breath, focus, posture
- **Prescription:** Sit 510 min, attention on the breath, returning to it whenever the mind wanders
- **Defaults:** 1 × 300 s
## Setup
Sit on the floor (or a cushion) with the legs crossed — lotus, half-lotus, or a
simple cross, whatever the hips allow. Stack the spine tall, rest the hands on
the knees, and soften the gaze or close the eyes.
## Execution
1. Lengthen up through the crown of the head, letting the shoulders drop.
2. Breathe slowly through the nose — a long inhale, a longer exhale.
3. Rest the attention on the breath moving in and out.
4. When the mind wanders, notice it without judgment and return to the breath.
## Cues
- Sit on the front edge of a cushion to tilt the pelvis and free the spine.
- Let the breath deepen on its own — observe it rather than forcing it.
- Keep the face, jaw, and hands soft; effort lives only in the tall spine.
## Common Mistakes
- Slumping through the lower back as the sit goes on.
- Chasing every thought instead of letting it pass.
- Straining into a full lotus the hips aren't ready for — a simple cross works.
## Progression
A few minutes seated comfortably → longer sits with a steady tall spine →
half-lotus or full lotus as hip mobility allows.
@@ -0,0 +1,30 @@
{
"name": "Meditation",
"primary": 1,
"camera": {"yaw": 45},
"working": ["spine"],
"frames": [
{
"hold": 1.2, "tween": 2.6,
"root": {"pos": [160, 143], "pitch": 2},
"spine": [6, 2],
"neck": 4, "head": -8,
"shoulder_r": {"flexion": 30, "abduction": 8, "rotation": -25}, "elbow_r": 100,
"shoulder_l": {"flexion": 30, "abduction": 8, "rotation": -25}, "elbow_l": 100,
"hip_r": {"flexion": 88, "abduction": 45, "rotation": 50}, "knee_r": 146, "ankle_r": 0,
"hip_l": {"flexion": 88, "abduction": 45, "rotation": 50}, "knee_l": 146, "ankle_l": 0,
"pins": {"hand_r": [174, 95], "hand_l": [176, 96]}
},
{
"hold": 0.8, "tween": 2.8,
"root": {"pos": [160, 142], "pitch": 1},
"spine": [3, -2],
"neck": 2, "head": -6,
"shoulder_r": {"flexion": 33, "abduction": 8, "rotation": -25}, "elbow_r": 96,
"shoulder_l": {"flexion": 33, "abduction": 8, "rotation": -25}, "elbow_l": 96,
"hip_r": {"flexion": 88, "abduction": 45, "rotation": 50}, "knee_r": 146, "ankle_r": 0,
"hip_l": {"flexion": 88, "abduction": 45, "rotation": 50}, "knee_l": 146, "ankle_l": 0,
"pins": {"hand_r": [174, 93], "hand_l": [176, 94]}
}
]
}
@@ -0,0 +1,23 @@
{
"activityType" : 8,
"color" : "purple",
"createdAt" : "2020-01-01T00:00:00Z",
"exercises" : [
{
"durationSeconds" : 300,
"id" : "01DXF6DT00SNZFGBWZ6JACASCQ",
"loadType" : 2,
"name" : "Meditation",
"order" : 0,
"reps" : 0,
"sets" : 1,
"weight" : 0
}
],
"id" : "01DXF6DT00ANFJR7GX9G808G4M",
"name" : "Meditation",
"order" : 13,
"schemaVersion" : 3,
"systemImage" : "figure.mind.and.body",
"updatedAt" : "2020-01-01T00:00:00Z"
}
+11 -1
View File
@@ -26,7 +26,17 @@ struct ScreenshotRootView: View {
@ViewBuilder
private var content: some View {
if let workout = activeWorkout {
// The full tab shell and the Progress tab need no particular workout,
// so they're handled before the active-workout unwrap.
if ScreenshotSeed.screen(default: "workouts") == "today" {
ContentView()
} else if ScreenshotSeed.screen(default: "workouts") == "progress" {
ProgressTabView()
} else if ScreenshotSeed.screen(default: "workouts") == "progress-bottom" {
ProgressTabView(initialAnchor: .bottom)
} else if ScreenshotSeed.screen(default: "workouts") == "exercise-trends" {
NavigationStack { ExerciseTrendsListView() }
} else if let workout = activeWorkout {
switch ScreenshotSeed.screen(default: "workouts") {
case "exercise":
let logID = WorkoutDocument(from: workout).logs.first { $0.exerciseName == "Seated Row" }?.id
+12 -8
View File
@@ -1,13 +1,15 @@
import Foundation
import IndieSync
/// The bundled immutable starter-split library. Each seed ships as byte-canonical
/// `SplitDocument` JSON in `Resources/StarterSplits/*.split.json`, keyed by a fixed
/// ULID (the shared `01DXF6DT00` prefix, minted from a frozen 2020 timestamp) so
/// every install and device agrees on the same identity.
/// The bundled immutable starter-routine library. Each seed ships as byte-canonical
/// `RoutineDocument` JSON in `Resources/StarterSplits/*.split.json` (the resource
/// directory and file extension keep their legacy "split" spelling bundled asset
/// names are pinned, see below), keyed by a fixed ULID (the shared `01DXF6DT00`
/// prefix, minted from a frozen 2020 timestamp) so every install and device agrees
/// on the same identity.
///
/// Seeds are never mutated in place: editing one clones it to a fresh random ULID
/// and soft-deletes the seed (see `SyncEngine.save(split:)`). The seed's tombstone
/// and soft-deletes the seed (see `SyncEngine.save(routine:)`). The seed's tombstone
/// is exempt from pruning, so it vetoes resurrection forever; the app bundle not
/// the stub is the canonical restore source, since the seed content is immutable.
enum SeedLibrary {
@@ -17,7 +19,7 @@ enum SeedLibrary {
/// seeding at once is then semantically empty (both wrote the same bytes).
struct Seed: Sendable {
let id: String
let doc: SplitDocument
let doc: RoutineDocument
let data: Data
}
@@ -31,11 +33,13 @@ enum SeedLibrary {
let bundle = Bundle(for: BundleToken.self)
// XcodeGen flattens resource groups, so the files land in the bundle root as
// "<Name>.split.json" enumerate all json and filter on the compound suffix.
// PINNED: the bundled resource extension stays ".split.json" (asset names in
// StarterSplits/ are shipped files, not symbols the rename must not touch them).
let urls = (bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? [])
.filter { $0.lastPathComponent.hasSuffix(".split.json") }
let loaded: [Seed] = urls.compactMap { url in
guard let data = try? Data(contentsOf: url),
let doc = try? DocumentCoder.decode(SplitDocument.self, from: data)
let doc = try? DocumentCoder.decode(RoutineDocument.self, from: data)
else { return nil }
return Seed(id: doc.id, doc: doc, data: data)
}
@@ -53,7 +57,7 @@ enum SeedLibrary {
/// would otherwise read as an edit and fork the seed; normalizing both timestamps
/// to a common value before the `==` isolates real changes. A non-seed id has no
/// seed to compare against treated as pristine (callers gate on `isSeed` first).
static func isPristine(_ doc: SplitDocument) -> Bool {
static func isPristine(_ doc: RoutineDocument) -> Bool {
guard let seed = seed(id: doc.id) else { return true }
var edited = doc
var pristine = seed.doc
+1 -1
View File
@@ -112,7 +112,7 @@ enum SpeechSettings {
}
}
/// What the automatic workout narration says while a flow-mode split runs, chosen in
/// What the automatic workout narration says while a flow-mode routine runs, chosen in
/// Settings (only meaningful when "Speak Exercise Cues" is on). Stored as its raw string
/// under `spokenCueStyle`, read by both the Settings picker and the run flow.
enum SpokenCueStyle: String, CaseIterable, Identifiable {
+34 -33
View File
@@ -3,22 +3,22 @@ import Foundation
// Pure planning logic for the developer-facing "duplicate cleanup" tool. No I/O
// and no `SyncEngine` dependency, so it's fully unit-testable in isolation:
// `SyncEngine.scanForDuplicates()` gathers the already-decoded documents and the
// referenced-split-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and
// referenced-routine-id set, hands them to `DuplicateCleanupPlanner.plan(...)`, and
// `SyncEngine.performCleanup(_:)` executes the resulting plan file by file.
//
// Duplicates are detected by a *content* fingerprint that deliberately ignores
// identity (ids), timestamps, and cosmetic fields two splits/workouts that were
// identity (ids), timestamps, and cosmetic fields two routines/workouts that were
// created independently (e.g. by a sync hiccup, a restore, or manual testing) but
// carry the same real content are duplicates even though every id differs.
// MARK: - Plan
struct DuplicateCleanupPlan: Sendable {
struct SplitGroup: Sendable, Identifiable {
struct RoutineGroup: Sendable, Identifiable {
/// Members that survive always non-empty when the group is emitted.
var keep: [SplitDocument]
var keep: [RoutineDocument]
/// Members slated for deletion always non-empty when the group is emitted.
var delete: [SplitDocument]
var delete: [RoutineDocument]
var id: String {
keep.isEmpty ? (delete.first?.id ?? "") : keep.map(\.id).joined(separator: ",")
@@ -32,14 +32,14 @@ struct DuplicateCleanupPlan: Sendable {
var id: String { keep.id }
}
var splitGroups: [SplitGroup]
var routineGroups: [RoutineGroup]
var workoutGroups: [WorkoutGroup]
var isEmpty: Bool { splitGroups.isEmpty && workoutGroups.isEmpty }
var isEmpty: Bool { routineGroups.isEmpty && workoutGroups.isEmpty }
/// Total number of documents this plan would delete across every group.
var deleteCount: Int {
splitGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count }
routineGroups.reduce(0) { $0 + $1.delete.count } + workoutGroups.reduce(0) { $0 + $1.delete.count }
}
}
@@ -47,22 +47,23 @@ struct DuplicateCleanupPlan: Sendable {
enum DuplicateCleanupPlanner {
/// Builds a cleanup plan from already-decoded, already-`isReadable`-filtered
/// documents. `referencedSplitIDs` must include every split id any workout
/// (readable or quarantined) points at, plus any clone-redirect targets the
/// caller (`SyncEngine.scanForDuplicates`) is responsible for assembling it.
/// documents. `referencedRoutineIDs` must include every routine id any workout
/// or schedule (readable or quarantined) points at, plus any clone-redirect
/// targets the caller (`SyncEngine.scanForDuplicates`) is responsible for
/// assembling it.
static func plan(
splits: [SplitDocument],
routines: [RoutineDocument],
workouts: [WorkoutDocument],
referencedSplitIDs: Set<String>,
referencedRoutineIDs: Set<String>,
isSeed: (String) -> Bool
) -> DuplicateCleanupPlan {
DuplicateCleanupPlan(
splitGroups: planSplits(splits, referencedSplitIDs: referencedSplitIDs, isSeed: isSeed),
routineGroups: planRoutines(routines, referencedRoutineIDs: referencedRoutineIDs, isSeed: isSeed),
workoutGroups: planWorkouts(workouts)
)
}
// MARK: Splits
// MARK: Routines
/// Hashable projection of a `MachineSetting`. The fingerprints keep the
/// nil / empty distinction (non-machine vs. machine-with-nothing-recorded)
@@ -88,15 +89,15 @@ enum DuplicateCleanupPlanner {
var machineSettings: [MachineSettingFingerprint]?
}
/// Content-only projection of a split: ignores id, dates, color, systemImage,
/// Content-only projection of a routine: ignores id, dates, color, systemImage,
/// order, and activityType.
private struct SplitFingerprint: Hashable {
private struct RoutineFingerprint: Hashable {
var name: String
var exercises: [ExerciseFingerprint]
}
private static func fingerprint(_ doc: SplitDocument) -> SplitFingerprint {
SplitFingerprint(
private static func fingerprint(_ doc: RoutineDocument) -> RoutineFingerprint {
RoutineFingerprint(
name: doc.name.trimmingCharacters(in: .whitespacesAndNewlines),
exercises: doc.exercises.sorted { $0.order < $1.order }.map {
ExerciseFingerprint(
@@ -115,19 +116,19 @@ enum DuplicateCleanupPlanner {
/// (ULIDs sort chronologically) survives deterministic across devices.
/// Everyone else in the group is deleted. A group with nothing to delete
/// (e.g. every member is protected) is dropped entirely.
private static func planSplits(
_ splits: [SplitDocument],
referencedSplitIDs: Set<String>,
private static func planRoutines(
_ routines: [RoutineDocument],
referencedRoutineIDs: Set<String>,
isSeed: (String) -> Bool
) -> [DuplicateCleanupPlan.SplitGroup] {
let grouped = Dictionary(grouping: splits, by: fingerprint)
var groups: [DuplicateCleanupPlan.SplitGroup] = []
) -> [DuplicateCleanupPlan.RoutineGroup] {
let grouped = Dictionary(grouping: routines, by: fingerprint)
var groups: [DuplicateCleanupPlan.RoutineGroup] = []
for (_, members) in grouped where members.count > 1 {
var keep: [SplitDocument] = []
var candidates: [SplitDocument] = []
var keep: [RoutineDocument] = []
var candidates: [RoutineDocument] = []
for member in members {
if referencedSplitIDs.contains(member.id) || isSeed(member.id) {
if referencedRoutineIDs.contains(member.id) || isSeed(member.id) {
keep.append(member)
} else {
candidates.append(member)
@@ -138,7 +139,7 @@ enum DuplicateCleanupPlanner {
candidates.removeAll { $0.id == survivorID }
}
guard !candidates.isEmpty else { continue }
groups.append(DuplicateCleanupPlan.SplitGroup(
groups.append(DuplicateCleanupPlan.RoutineGroup(
keep: keep.sorted { $0.id < $1.id },
delete: candidates.sorted { $0.id < $1.id }
))
@@ -169,8 +170,8 @@ enum DuplicateCleanupPlanner {
/// startedAt/completedAt, exact start/end time (only the calendar day of
/// `start` matters), and metrics.
private struct WorkoutFingerprint: Hashable {
var splitID: String
var splitName: String
var routineID: String
var routineName: String
var year: Int
var month: Int
var day: Int
@@ -181,8 +182,8 @@ enum DuplicateCleanupPlanner {
private static func fingerprint(_ doc: WorkoutDocument) -> WorkoutFingerprint {
let comps = Calendar.current.dateComponents([.year, .month, .day], from: doc.start)
return WorkoutFingerprint(
splitID: doc.splitID ?? "",
splitName: doc.splitName ?? "",
routineID: doc.routineID ?? "",
routineName: doc.routineName ?? "",
year: comps.year ?? 0,
month: comps.month ?? 0,
day: comps.day ?? 0,
+15 -15
View File
@@ -1,12 +1,12 @@
import Foundation
// Pure decision logic for bringing an install's on-disk starter-split library in
// Pure decision logic for bringing an install's on-disk starter-routine library in
// line with the current app bundle. No I/O and no `SyncEngine` dependency, so the
// safety-critical part is fully unit-testable in isolation: `SyncEngine.reconcileSeeds()`
// gathers the live seed-path files, stub set, and live split names, hands each seed to
// gathers the live seed-path files, stub set, and live routine names, hands each seed to
// `SeedReconcilePlanner.decision(...)`, and executes the returned skip / upgrade / write.
//
// Why this exists: starter seeds ship as byte-canonical `SplitDocument` JSON under
// Why this exists: starter seeds ship as byte-canonical `RoutineDocument` JSON under
// FIXED ULIDs (see `SeedLibrary`). `autoSeedIfEmpty` only ever writes them into a
// verifiably empty container, so an existing install can never pick up an upgraded
// seed revision or a newly-shipped seed. This planner decides, per seed, whether the
@@ -26,10 +26,10 @@ struct SeedReconcileInput: Equatable {
/// The seed's fixed ULID.
let seedID: String
/// The bundle seed document (canonical current content).
let seedDoc: SplitDocument
let seedDoc: RoutineDocument
/// The decoded live file currently sitting at the seed's fixed path, or `nil`
/// when no live file exists there (deleted, or never written).
let liveDoc: SplitDocument?
let liveDoc: RoutineDocument?
/// Whether a tombstone stub exists for this seed's id.
let hasStub: Bool
}
@@ -54,7 +54,7 @@ enum SeedSkipReason: Equatable {
case quarantined
/// No live file, but a stub exists: the user deleted this seed. Respect the veto.
case vetoed
/// No live file and no stub, but a live split already uses this seed's name
/// No live file and no stub, but a live routine already uses this seed's name
/// (e.g. a legacy runtime-built or user-cloned "Upper Body"). Writing the seed
/// would duplicate it by name.
case nameCollision
@@ -64,11 +64,11 @@ enum SeedSkipReason: Equatable {
enum SeedReconcilePlanner {
/// The automatic on-connect decision for one seed (see the decision table in the
/// file header). `liveSplitNames` must be the names of every live split currently
/// file header). `liveRoutineNames` must be the names of every live routine currently
/// in the container/cache after reconcile the name-collision guard consults it.
static func decision(
for input: SeedReconcileInput,
liveSplitNames: Set<String>
liveRoutineNames: Set<String>
) -> SeedReconcileDecision {
if let live = input.liveDoc {
// A live file sits at the seed path. Never touch one a newer app version
@@ -80,7 +80,7 @@ enum SeedReconcilePlanner {
}
// No live file at the seed path.
if input.hasStub { return .skip(.vetoed) }
if liveSplitNames.contains(input.seedDoc.name) { return .skip(.nameCollision) }
if liveRoutineNames.contains(input.seedDoc.name) { return .skip(.nameCollision) }
return .write
}
@@ -88,27 +88,27 @@ enum SeedReconcilePlanner {
///
/// A byte compare is wrong here: a container file may have been written by an
/// older app version, carrying extra now-removed keys and a lower `schemaVersion`.
/// Decoding both into the current `SplitDocument` already drops unknown keys, so
/// Decoding both into the current `RoutineDocument` already drops unknown keys, so
/// the only remaining artificial difference is the version stamp normalize the
/// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of
/// identical content reads as up to date (no churn), while a genuine content
/// change reads as different (upgrade).
static func semanticallyEqual(live: SplitDocument, seed: SplitDocument) -> Bool {
static func semanticallyEqual(live: RoutineDocument, seed: RoutineDocument) -> Bool {
var normalized = live
normalized.schemaVersion = seed.schemaVersion
return normalized == seed
}
/// Whether the deliberate "Restore Starter Splits" action should (re)write a seed:
/// only when no live file sits at its path and no live split already uses its name.
/// Whether the deliberate "Restore Starter Routines" action should (re)write a seed:
/// only when no live file sits at its path and no live routine already uses its name.
/// Unlike `decision(...)` a stub does NOT veto here restore lifts the veto but
/// the engine still removes the stub as a separate execution step. A seed whose
/// live file already exists is left for `decision(...)` to upgrade.
static func shouldRestore(
hasLiveFile: Bool,
seedName: String,
liveSplitNames: Set<String>
liveRoutineNames: Set<String>
) -> Bool {
!hasLiveFile && !liveSplitNames.contains(seedName)
!hasLiveFile && !liveRoutineNames.contains(seedName)
}
}
+180 -93
View File
@@ -55,13 +55,13 @@ final class SyncEngine {
/// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
/// holding the seed's id resolves to the live clone. In-memory only (@Observable
/// notifies on change); not persisted durability comes from `repointWorkouts`,
/// which rewrites `splitID` on the workout documents themselves at fork time.
/// which rewrites `routineID` on the workout documents themselves at fork time.
/// This map only bridges views that captured the seed's id before the swap.
private(set) var cloneRedirects: [String: String] = [:]
/// Follow the redirect chain from `id` to the current live split id. A seed
/// Follow the redirect chain from `id` to the current live routine id. A seed
/// redirects at most once, but the loop tolerates (and breaks) any chain or cycle.
func currentSplitID(for id: String) -> String {
func currentRoutineID(for id: String) -> String {
var current = id
var seen: Set<String> = [current]
while let next = cloneRedirects[current], seen.insert(next).inserted {
@@ -74,6 +74,14 @@ final class SyncEngine {
/// this to push fresh state to the watch.
var onCacheChanged: (() -> Void)?
/// Fired when a phone-side save first moves a workout from `.notStarted` into
/// `.inProgress` the moment a run actually begins. AppServices launches the
/// watch session here rather than at creation, so peeking into a routine never
/// spins one up. Watch-originated updates (`ingestFromWatch`) never fire it
/// the watch already owns a session and neither does editing an old finished
/// workout back to in-progress (the transition must be from `.notStarted`).
var onWorkoutBecameActive: ((WorkoutDocument) -> Void)?
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
private let modelContainer: ModelContainer
private var store: DocumentFileStore?
@@ -176,7 +184,9 @@ final class SyncEngine {
}
}
log.info("connect[\(attempt)]: preparing directories…")
await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
// PINNED: the iCloud directory is "Splits" (routine documents live under it).
// The directory name is on-disk data the SplitRoutine rename must not touch it.
await store.prepareDirectories(["Splits", "Workouts", "Schedules", "Stubs"])
safety.cancel()
guard attempt == connectAttempt else { return }
@@ -323,21 +333,21 @@ final class SyncEngine {
onCacheChanged?()
return
}
await save(workout: merged)
await save(workout: merged, notifyingActive: false)
} else {
// First time we've seen this workout nothing to merge against.
await save(workout: doc)
await save(workout: doc, notifyingActive: false)
}
}
// MARK: - Public CRUD (mirror-first: cache now, file via the write queue)
/// Returns the *effective* id of the split that was written normally `doc.id`,
/// Returns the *effective* id of the routine that was written normally `doc.id`,
/// but the clone's fresh id when an edited seed forks. Open views follow the swap
/// via `currentSplitID(for:)`.
/// via `currentRoutineID(for:)`.
@discardableResult
func save(split doc: SplitDocument) async -> String {
// Seeds are immutable: a real edit forks the seed into a user-owned split and
func save(routine doc: RoutineDocument) async -> String {
// Seeds are immutable: a real edit forks the seed into a user-owned routine and
// soft-deletes the original, keeping the curated seed intact and restorable. A
// pristine (no-op) save must NOT fork the edit sheets stamp `updatedAt`
// unconditionally, so merely opening and saving a seed's editor without a change
@@ -346,19 +356,19 @@ final class SyncEngine {
return await cloneSeedOnEdit(doc)
}
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
CacheMapper.upsertRoutine(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
enqueueWrite(.split(doc), timestamp: doc.updatedAt)
enqueueWrite(.routine(doc), timestamp: doc.updatedAt)
return doc.id
}
/// Fork an edited seed into a user-owned split. Writes the clone under a fresh
/// Fork an edited seed into a user-owned routine. Writes the clone under a fresh
/// ULID and upserts it into the cache immediately (same rationale as
/// `ingestFromWatch`: a same-process write doesn't reliably wake the metadata
/// observer, and open screens must see the clone the moment `save` returns), then
/// soft-deletes the seed and drops its cache entity. Records the id redirect so a
/// view still holding the seed's id follows the identity swap.
private func cloneSeedOnEdit(_ doc: SplitDocument) async -> String {
private func cloneSeedOnEdit(_ doc: RoutineDocument) async -> String {
guard let store else { return doc.id }
var clone = doc
clone.id = ULID.make()
@@ -366,9 +376,10 @@ final class SyncEngine {
// Exercise ids are kept as-is they only need uniqueness within the document.
do {
try await store.write(clone, to: clone.relativePath)
CacheMapper.upsertSplit(clone, relativePath: clone.relativePath, into: context)
CacheMapper.upsertRoutine(clone, relativePath: clone.relativePath, into: context)
// Soft-delete the seed (writes its veto stub, then removes the live file),
// then evict its now-orphaned cache entity.
// PINNED: tombstone kind stays "split" existing stubs on disk carry it.
await softDelete(id: doc.id, kind: "split", livePath: doc.relativePath)
deleteCachedEntity(id: doc.id)
try context.save()
@@ -378,63 +389,73 @@ final class SyncEngine {
onCacheChanged?()
return clone.id
} catch {
report("Failed to fork edited starter split", error)
report("Failed to fork edited starter routine", error)
return doc.id
}
}
/// Rewrite `splitID` on every workout that references `oldID`, so split lookups
/// Rewrite `routineID` on every workout that references `oldID`, so routine lookups
/// (category grouping, add-exercise, plan mirroring, health estimates) keep
/// resolving after a seed fork durably, across relaunches, and on the watch,
/// which the in-memory `cloneRedirects` map can't reach. `splitName` stays frozen
/// which the in-memory `cloneRedirects` map can't reach. `routineName` stays frozen
/// at what the workout was started as, matching rename semantics for regular
/// splits. Best effort per workout: a failed rewrite is reported and the redirect
/// routines. Best effort per workout: a failed rewrite is reported and the redirect
/// map still covers it for this session.
private func repointWorkouts(from oldID: String, to newID: String) async {
guard let store else { return }
let referencing = (try? context.fetch(
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == oldID })
FetchDescriptor<Workout>(predicate: #Predicate { $0.routineID == oldID })
)) ?? []
guard !referencing.isEmpty else { return }
for workout in referencing {
var wDoc = WorkoutDocument(from: workout)
wDoc.splitID = newID
wDoc.routineID = newID
wDoc.updatedAt = Date()
do {
try await store.write(wDoc, to: wDoc.relativePath)
CacheMapper.upsertWorkout(wDoc, relativePath: wDoc.relativePath, into: context)
} catch {
report("Failed to repoint workout \(wDoc.id) at edited split", error)
report("Failed to repoint workout \(wDoc.id) at edited routine", error)
}
}
do { try context.save() } catch { report("Cache save failed", error) }
}
/// Push edited machine settings onto the originating split's exercise (matched
/// Push edited machine settings onto the originating routine's exercise (matched
/// by name logs reference exercises by name only), following the seed
/// clone-on-edit redirect so the live clone is updated. Skips silently when the
/// split is gone or no exercise matches, and when nothing changed (so a pristine
/// seed isn't needlessly forked). Never caches the split's id across the save,
/// routine is gone or no exercise matches, and when nothing changed (so a pristine
/// seed isn't needlessly forked). Never caches the routine's id across the save,
/// since saving a seed mints a new one.
func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, splitID: String?) async {
guard let splitID else { return }
let liveID = currentSplitID(for: splitID)
guard let split = CacheMapper.fetchSplit(id: liveID, in: context) else { return }
var splitDoc = SplitDocument(from: split)
guard let idx = splitDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
guard splitDoc.exercises[idx].machineSettings != settings else { return }
splitDoc.exercises[idx].machineSettings = settings
splitDoc.updatedAt = Date()
await save(split: splitDoc)
func writeBackMachineSettings(_ settings: [MachineSetting], exerciseName: String, routineID: String?) async {
guard let routineID else { return }
let liveID = currentRoutineID(for: routineID)
guard let routine = CacheMapper.fetchRoutine(id: liveID, in: context) else { return }
var routineDoc = RoutineDocument(from: routine)
guard let idx = routineDoc.exercises.firstIndex(where: { $0.name == exerciseName }) else { return }
guard routineDoc.exercises[idx].machineSettings != settings else { return }
routineDoc.exercises[idx].machineSettings = settings
routineDoc.updatedAt = Date()
await save(routine: routineDoc)
}
func save(workout doc: WorkoutDocument) async {
await save(workout: doc, notifyingActive: true)
}
/// `notifyingActive: false` is the watch-ingest path the watch already runs
/// its own session, so a watch-originated update must not fire the
/// became-active hook (which would launch a second session at the wrist).
private func save(workout doc: WorkoutDocument, notifyingActive: Bool) async {
// The month bucket in a workout's path derives from `start`, so editing the
// start date (or a device in a different time zone) can move the file to a new
// path. Capture the previously-written path before the upsert overwrites it
// the queued write removes it after landing, otherwise the same id would live
// at two paths and the old copy would re-import on the next reconcile.
let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
// (Both reads happen before the upsert mutates the same entity in place.)
let previous = CacheMapper.fetchWorkout(id: doc.id, in: context)
let previousPath = previous?.jsonRelativePath
let previousStatus = previous?.status
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
enqueueWrite(
@@ -442,12 +463,26 @@ final class SyncEngine {
timestamp: doc.updatedAt,
stalePath: previousPath != doc.relativePath ? previousPath : nil
)
if notifyingActive, previousStatus == .notStarted,
doc.status == WorkoutStatus.inProgress.rawValue {
onWorkoutBecameActive?(doc)
}
}
func delete(split: Split) async {
let id = split.id, livePath = split.jsonRelativePath
/// Persist a schedule. Schedules live in a flat `Schedules/` directory (no month
/// bucketing), so unlike `save(workout:)` a save can never move the file to a
/// new path: mirror the cache, then queue the plain write.
func save(schedule doc: ScheduleDocument) async {
CacheMapper.upsertSchedule(doc, relativePath: doc.relativePath, into: context)
saveCacheAndNotify()
enqueueWrite(.schedule(doc), timestamp: doc.updatedAt)
}
func delete(routine: Routine) async {
let id = routine.id, livePath = routine.jsonRelativePath
deleteCachedEntity(id: id)
saveCacheAndNotify()
// PINNED: tombstone kind stays "split" existing stubs on disk carry it.
enqueueWrite(.delete(id: id, kind: "split", livePath: livePath), timestamp: Date())
}
@@ -458,6 +493,13 @@ final class SyncEngine {
enqueueWrite(.delete(id: id, kind: "workout", livePath: livePath), timestamp: Date())
}
func delete(schedule: Schedule) async {
let id = schedule.id, livePath = schedule.jsonRelativePath
deleteCachedEntity(id: id)
saveCacheAndNotify()
enqueueWrite(.delete(id: id, kind: "schedule", livePath: livePath), timestamp: Date())
}
/// Persist pending cache mutations and fan out the change notification the
/// tail of every local write's immediate cache mirror.
private func saveCacheAndNotify() {
@@ -552,10 +594,12 @@ final class SyncEngine {
guard let store, let tombstones else { return .retry }
do {
switch entry.payload {
case .split(let doc):
case .routine(let doc):
try await store.write(doc, to: doc.relativePath)
case .workout(let doc):
try await store.write(doc, to: doc.relativePath)
case .schedule(let doc):
try await store.write(doc, to: doc.relativePath)
case .delete(let id, let kind, let livePath):
// The stub is the authoritative delete record (it vetoes
// resurrection everywhere); the live-file removal is best-effort
@@ -635,16 +679,23 @@ final class SyncEngine {
return
}
// PINNED: routine documents live under the "Splits/" directory on disk the
// path prefix is data, unchanged by the SplitRoutine symbol rename.
if relativePath.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
guard let doc = try? DocumentCoder.decode(RoutineDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
CacheMapper.upsertRoutine(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Schedules/") {
guard let doc = try? DocumentCoder.decode(ScheduleDocument.self, from: data), doc.isReadable else { return }
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
CacheMapper.upsertSchedule(doc, relativePath: relativePath, into: context)
}
}
@@ -661,8 +712,9 @@ final class SyncEngine {
let tombstoned = await tombstones.listStubIDs()
let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }
var liveSplitIDs = Set<String>()
var liveRoutineIDs = Set<String>()
var liveWorkoutIDs = Set<String>()
var liveScheduleIDs = Set<String>()
var unreadablePaths = Set<String>()
for path in dataFiles {
@@ -680,18 +732,25 @@ final class SyncEngine {
unreadablePaths.insert(path)
continue
}
// PINNED: routine documents live under "Splits/" on disk (see importFile).
if path.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
guard let doc = try? DocumentCoder.decode(RoutineDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
liveSplitIDs.insert(doc.id)
liveRoutineIDs.insert(doc.id)
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
CacheMapper.upsertRoutine(doc, relativePath: path, into: context)
} else if path.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
liveWorkoutIDs.insert(doc.id)
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
} else if path.hasPrefix("Schedules/") {
guard let doc = try? DocumentCoder.decode(ScheduleDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
liveScheduleIDs.insert(doc.id)
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
CacheMapper.upsertSchedule(doc, relativePath: path, into: context)
}
}
@@ -700,8 +759,8 @@ final class SyncEngine {
// right now; pruning would make an eviction look like a deletion), and
// never for an id with a queued-but-unwritten write (its file simply
// hasn't landed yet; pruning would evaporate the pending edit's mirror).
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
for s in splits where !liveSplitIDs.contains(s.id)
if let routines = try? context.fetch(FetchDescriptor<Routine>()) {
for s in routines where !liveRoutineIDs.contains(s.id)
&& !unreadablePaths.contains(s.jsonRelativePath)
&& backlog.pendingWrite(for: s.id) == nil {
context.delete(s)
@@ -714,6 +773,13 @@ final class SyncEngine {
context.delete(w)
}
}
if let schedules = try? context.fetch(FetchDescriptor<Schedule>()) {
for s in schedules where !liveScheduleIDs.contains(s.id)
&& !unreadablePaths.contains(s.jsonRelativePath)
&& backlog.pendingWrite(for: s.id) == nil {
context.delete(s)
}
}
do {
try context.save()
// A fully clean pass supersedes any earlier failure; a pass with
@@ -728,8 +794,9 @@ final class SyncEngine {
// MARK: - Cache deletes
private func deleteCachedEntity(id: String) {
if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
if let s = CacheMapper.fetchRoutine(id: id, in: context) { context.delete(s) }
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
if let sc = CacheMapper.fetchSchedule(id: id, in: context) { context.delete(sc) }
}
/// Observer `.removed` handling. Skips entities with a queued-but-unwritten
@@ -737,12 +804,15 @@ final class SyncEngine {
/// raced the rewrite), and the pending edit's mirror must survive until the
/// drain delivers it.
private func deleteCachedEntity(jsonRelativePath path: String) {
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
splits.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
if let routines = try? context.fetch(FetchDescriptor<Routine>(predicate: #Predicate { $0.jsonRelativePath == path })) {
routines.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
}
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
workouts.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
}
if let schedules = try? context.fetch(FetchDescriptor<Schedule>(predicate: #Predicate { $0.jsonRelativePath == path })) {
schedules.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
}
}
private func idFromStubPath(_ path: String) -> String {
@@ -804,7 +874,7 @@ final class SyncEngine {
// Verbatim bundle bytes: byte-identical files across devices make a
// same-path conflict (two devices seeding at once) semantically empty.
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
} catch {
report("Failed to seed \(seed.doc.name)", error)
}
@@ -837,7 +907,7 @@ final class SyncEngine {
/// live file present, older revision overwrite with bundle bytes (upgrade)
/// live file present, newer app version skip (quarantine never downgrade)
/// no live file, stub present skip (user deleted it; veto stands)
/// no live file, no stub, name in use skip (don't duplicate a same-name split)
/// no live file, no stub, name in use skip (don't duplicate a same-name routine)
/// no live file, no stub, name free write bundle bytes
///
/// Safe even on a stale metadata index: writing bundle bytes another device also
@@ -848,17 +918,17 @@ final class SyncEngine {
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
let stubIDs = await tombstones.listStubIDs()
let liveSplitNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
let liveRoutineNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
var didChange = false
for seed in SeedLibrary.seeds {
// Decode the live file at the seed's fixed path, if one exists. A file that
// exists but can't be read right now (evicted + download timed out) is left
// untouched a later connect retries it.
var liveDoc: SplitDocument?
var liveDoc: RoutineDocument?
if livePaths.contains(seed.doc.relativePath) {
guard let data = try? await store.readData(from: seed.doc.relativePath),
let decoded = try? DocumentCoder.decode(SplitDocument.self, from: data) else {
let decoded = try? DocumentCoder.decode(RoutineDocument.self, from: data) else {
continue
}
liveDoc = decoded
@@ -869,7 +939,7 @@ final class SyncEngine {
hasStub: stubIDs.contains(seed.id)
)
switch SeedReconcilePlanner.decision(for: input, liveSplitNames: liveSplitNames) {
switch SeedReconcilePlanner.decision(for: input, liveRoutineNames: liveRoutineNames) {
case .skip:
continue
case .upgrade:
@@ -880,7 +950,7 @@ final class SyncEngine {
// rewriting the seed bytes here would transiently resurrect the deleted
// seed alongside the user's clone. The stub now present vetoes that.
// (No name-collision guard here: on a legitimate upgrade the seed itself
// is a live split by that name, so a name check would block every upgrade.)
// is a live routine by that name, so a name check would block every upgrade.)
if await tombstones.stubExists(id: seed.id) { continue }
// Overwriting an existing seed-path file with canonical bundle bytes is
// otherwise always safe (the file can only ever be an older seed revision).
@@ -889,7 +959,7 @@ final class SyncEngine {
// Re-check the veto and name guards against fresh state the metadata
// index may have moved since the batch was gathered.
if await tombstones.stubExists(id: seed.id) { continue }
let names = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
let names = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
if names.contains(seed.doc.name) { continue }
if await writeSeedBytes(seed) { didChange = true }
}
@@ -902,7 +972,7 @@ final class SyncEngine {
}
/// Force-restore the starter library on explicit user request ("Restore Starter
/// Splits"). For each seed with no live file and no live same-name split, lift its
/// Routines"). For each seed with no live file and no live same-name routine, lift its
/// veto stub if present the ONE place the forever-veto is deliberately lifted
/// and write the CURRENT bundle bytes (not the stub's old contents; the bundle is
/// the canonical restore source). Seeds whose live file already exists are left for
@@ -915,11 +985,11 @@ final class SyncEngine {
var restored = 0
for seed in SeedLibrary.seeds {
let liveNames = Set(((try? context.fetch(FetchDescriptor<Split>())) ?? []).map(\.name))
let liveNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
guard SeedReconcilePlanner.shouldRestore(
hasLiveFile: livePaths.contains(seed.doc.relativePath),
seedName: seed.doc.name,
liveSplitNames: liveNames
liveRoutineNames: liveNames
) else { continue }
do {
@@ -929,11 +999,11 @@ final class SyncEngine {
try await tombstones.removeStub(at: "\(seed.id).json")
}
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
restored += 1
lastSyncError = nil
} catch {
report("Failed to restore starter split \(seed.doc.name)", error)
report("Failed to restore starter routine \(seed.doc.name)", error)
}
}
@@ -951,11 +1021,11 @@ final class SyncEngine {
guard let store else { return false }
do {
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
lastSyncError = nil
return true
} catch {
report("Failed to reconcile starter split \(seed.doc.name)", error)
report("Failed to reconcile starter routine \(seed.doc.name)", error)
return false
}
}
@@ -968,18 +1038,18 @@ final class SyncEngine {
}
/// Scans every live (non-stub) document and builds a plan for exact-content
/// duplicate splits/workouts (see `DuplicateCleanupPlanner`). Read-only no
/// duplicate routines/workouts (see `DuplicateCleanupPlanner`). Read-only no
/// files are touched. Fails closed: if ANY file can't be read or decoded, no
/// plan is produced, because an unreadable workout could reference any split
/// and silently proceeding could misjudge that split as unreferenced.
/// plan is produced, because an unreadable workout or schedule could reference
/// any routine and silently proceeding could misjudge it as unreferenced.
func scanForDuplicates() async throws -> DuplicateCleanupPlan {
guard let store else { throw DuplicateScanError.notConnected }
let paths = await store.list().filter { !$0.hasPrefix("Stubs/") }
var splits: [SplitDocument] = []
var routines: [RoutineDocument] = []
var workouts: [WorkoutDocument] = []
var referencedSplitIDs = Set<String>()
var referencedRoutineIDs = Set<String>()
var failedPaths: [String] = []
for path in paths {
@@ -992,12 +1062,13 @@ final class SyncEngine {
continue
}
// PINNED: routine documents live under "Splits/" on disk (see importFile).
if path.hasPrefix("Splits/") {
do {
let doc = try DocumentCoder.decode(SplitDocument.self, from: data)
// Quarantined (written by a newer app version) splits are never
let doc = try DocumentCoder.decode(RoutineDocument.self, from: data)
// Quarantined (written by a newer app version) routines are never
// judged as duplicates or deleted.
if doc.isReadable { splits.append(doc) }
if doc.isReadable { routines.append(doc) }
} catch {
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
@@ -1005,17 +1076,29 @@ final class SyncEngine {
} else if path.hasPrefix("Workouts/") {
do {
let doc = try DocumentCoder.decode(WorkoutDocument.self, from: data)
// A quarantined workout's splitID still protects that split from
// A quarantined workout's routineID still protects that routine from
// deletion even though the workout itself is excluded below.
if let splitID = doc.splitID {
referencedSplitIDs.insert(splitID)
referencedSplitIDs.insert(currentSplitID(for: splitID))
if let routineID = doc.routineID {
referencedRoutineIDs.insert(routineID)
referencedRoutineIDs.insert(currentRoutineID(for: routineID))
}
if doc.isReadable { workouts.append(doc) }
} catch {
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
}
} else if path.hasPrefix("Schedules/") {
do {
let doc = try DocumentCoder.decode(ScheduleDocument.self, from: data)
// Schedules are never duplicate candidates themselves, but the
// routine a schedule (even a quarantined one) points at must
// survive cleanup, or the schedule would dangle.
referencedRoutineIDs.insert(doc.routineID)
referencedRoutineIDs.insert(currentRoutineID(for: doc.routineID))
} catch {
log.error("scanForDuplicates: decode failed for \(path, privacy: .public): \(error)")
failedPaths.append(path)
}
}
}
@@ -1023,12 +1106,12 @@ final class SyncEngine {
throw DuplicateScanError.unreadableFiles(failedPaths.sorted())
}
referencedSplitIDs.formUnion(cloneRedirects.values)
referencedRoutineIDs.formUnion(cloneRedirects.values)
return DuplicateCleanupPlanner.plan(
splits: splits,
routines: routines,
workouts: workouts,
referencedSplitIDs: referencedSplitIDs,
referencedRoutineIDs: referencedRoutineIDs,
isSeed: SeedLibrary.isSeed(id:)
)
}
@@ -1037,32 +1120,36 @@ final class SyncEngine {
/// immediately beforehand against the *current* cache state a watch push or
/// another device's write can land between scan and delete so nothing
/// referenced or active is ever removed even if the plan is a few seconds stale.
func performCleanup(_ plan: DuplicateCleanupPlan) async -> (splitsDeleted: Int, workoutsDeleted: Int, skipped: Int) {
var splitsDeleted = 0
func performCleanup(_ plan: DuplicateCleanupPlan) async -> (routinesDeleted: Int, workoutsDeleted: Int, skipped: Int) {
var routinesDeleted = 0
var workoutsDeleted = 0
var skipped = 0
for group in plan.splitGroups {
for group in plan.routineGroups {
for doc in group.delete {
let splitID = doc.id
if SeedLibrary.isSeed(id: splitID) {
let routineID = doc.id
if SeedLibrary.isSeed(id: routineID) {
skipped += 1
continue
}
let referencingWorkouts = (try? context.fetch(
FetchDescriptor<Workout>(predicate: #Predicate { $0.splitID == splitID })
FetchDescriptor<Workout>(predicate: #Predicate { $0.routineID == routineID })
)) ?? []
guard referencingWorkouts.isEmpty else {
let referencingSchedules = (try? context.fetch(
FetchDescriptor<Schedule>(predicate: #Predicate { $0.routineID == routineID })
)) ?? []
guard referencingWorkouts.isEmpty, referencingSchedules.isEmpty else {
skipped += 1
continue
}
if let cached = CacheMapper.fetchSplit(id: splitID, in: context) {
await softDelete(id: splitID, kind: "split", livePath: cached.jsonRelativePath)
// PINNED: tombstone kind stays "split" existing stubs on disk carry it.
if let cached = CacheMapper.fetchRoutine(id: routineID, in: context) {
await softDelete(id: routineID, kind: "split", livePath: cached.jsonRelativePath)
} else {
await softDelete(id: splitID, kind: "split", livePath: doc.relativePath)
await softDelete(id: routineID, kind: "split", livePath: doc.relativePath)
}
deleteCachedEntity(id: splitID)
splitsDeleted += 1
deleteCachedEntity(id: routineID)
routinesDeleted += 1
}
}
@@ -1081,7 +1168,7 @@ final class SyncEngine {
}
saveCacheAndNotify()
return (splitsDeleted, workoutsDeleted, skipped)
return (routinesDeleted, workoutsDeleted, skipped)
}
// MARK: - Error Reporting
+1 -1
View File
@@ -64,7 +64,7 @@ enum WorkoutMergePlanner {
// 4. Prune tombstones past the grace period.
tombstones = tombstones.filter { now.timeIntervalSince($0.value) < tombstoneGrace }
// 5. Base scalars (splitID/name, start, metrics, ) on the newer document overall it
// 5. Base scalars (routineID/name, start, metrics, ) on the newer document overall it
// carries a just-attached metrics summary or a rename then override the reconciled
// parts. Ties prefer the cached copy (the phone is the sole writer). Sort is
// id-tie-broken so the result is deterministic even when two logs share an `order`.
+17 -3
View File
@@ -13,9 +13,21 @@ import Foundation
/// supersede checks key on it, newer wins.
struct PendingWrite: Codable, Sendable {
enum Payload: Codable, Sendable {
case split(SplitDocument)
case routine(RoutineDocument)
case workout(WorkoutDocument)
case schedule(ScheduleDocument)
case delete(id: String, kind: String, livePath: String)
// PINNED KEY: the case was renamed `.split` `.routine`, but its persisted
// JSON key stays `"split"` so a `PendingWrites.json` sidecar written by a
// previous run still decodes. `workout` / `schedule` / `delete` keep their
// default keys.
enum CodingKeys: String, CodingKey {
case routine = "split"
case workout
case schedule
case delete
}
}
var payload: Payload
@@ -36,8 +48,9 @@ struct PendingWrite: Codable, Sendable {
var documentID: String {
switch payload {
case .split(let doc): doc.id
case .routine(let doc): doc.id
case .workout(let doc): doc.id
case .schedule(let doc): doc.id
case .delete(let id, _, _): id
}
}
@@ -45,8 +58,9 @@ struct PendingWrite: Codable, Sendable {
/// Where a successful write lands; nil for deletes (they only remove).
var targetPath: String? {
switch payload {
case .split(let doc): doc.relativePath
case .routine(let doc): doc.relativePath
case .workout(let doc): doc.relativePath
case .schedule(let doc): doc.relativePath
case .delete: nil
}
}
@@ -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
/// fileobservercache 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 fileobservercache 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
}
}
@@ -18,8 +18,8 @@ struct ExerciseAddEditView: View {
// The exercise entity provides initial values (read-only).
let exercise: Exercise
// The parent split is needed to rebuild and save the SplitDocument.
let split: Split
// The parent routine is needed to rebuild and save the RoutineDocument.
let routine: Routine
// Local editable state
@State private var exerciseName: String
@@ -37,9 +37,9 @@ struct ExerciseAddEditView: View {
@State private var machineSettings: [MachineSetting]
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(exercise: Exercise, split: Split) {
init(exercise: Exercise, routine: Routine) {
self.exercise = exercise
self.split = split
self.routine = routine
// The tens/ones pickers step whole values; a fractional stored weight
// shows (and saves back) its whole part until UX #3's UI lands.
@@ -197,7 +197,7 @@ struct ExerciseAddEditView: View {
let newWeight = weightTens + weightOnes
let durationSecs = minutes * 60 + seconds
var doc = SplitDocument(from: split)
var doc = RoutineDocument(from: routine)
if let idx = doc.exercises.firstIndex(where: { $0.id == exercise.id }) {
doc.exercises[idx].name = exerciseName
doc.exercises[idx].sets = sets
@@ -209,6 +209,6 @@ struct ExerciseAddEditView: View {
doc.exercises[idx].machineSettings = machineEnabled ? machineSettings : nil
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
}
}
@@ -32,11 +32,11 @@ struct ExerciseLibraryDetailView: View {
let exerciseName: String
/// This exercise wherever it appears in the user's splits the source of the
/// This exercise wherever it appears in the user's routines the source of the
/// recorded machine settings.
@Query private var exercises: [Exercise]
/// Presents the settings editor; carries the splits the edit writes back to.
/// Presents the settings editor; carries the routines the edit writes back to.
@State private var settingsEdit: SettingsEditRoute?
/// True while content extends below the scroll area drives the bottom fade
@@ -63,31 +63,31 @@ struct ExerciseLibraryDetailView: View {
}
/// Distinct recorded machine-settings lists for this exercise. Usually one; when
/// splits disagree, each distinct list keeps its split's name as a label. Each
/// group carries every split showing that list, so an edit updates them all and
/// routines disagree, each distinct list keeps its routine's name as a label. Each
/// group carries every routine showing that list, so an edit updates them all and
/// they stay deduped.
private var settingsGroups: [SettingsGroup] {
var groups: [SettingsGroup] = []
for exercise in exercises {
guard let settings = exercise.machineSettings, !settings.isEmpty else { continue }
if let i = groups.firstIndex(where: { $0.settings == settings }) {
if let id = exercise.split?.id { groups[i].splitIDs.append(id) }
if let id = exercise.routine?.id { groups[i].routineIDs.append(id) }
continue
}
groups.append(SettingsGroup(
label: exercise.split?.name,
label: exercise.routine?.name,
settings: settings,
splitIDs: exercise.split.map { [$0.id] } ?? []
routineIDs: exercise.routine.map { [$0.id] } ?? []
))
}
if groups.count == 1 { groups[0].label = nil }
return groups
}
/// Every split containing this exercise the write targets when settings are
/// Every routine containing this exercise the write targets when settings are
/// recorded here for the first time.
private var containingSplitIDs: [String] {
exercises.compactMap { $0.split?.id }
private var containingRoutineIDs: [String] {
exercises.compactMap { $0.routine?.id }
}
var body: some View {
@@ -103,10 +103,10 @@ struct ExerciseLibraryDetailView: View {
MachineSettingsCard(
groups: [],
onEdit: edit(group:),
// Recording needs a split exercise to write to without
// Recording needs a routine exercise to write to without
// one the card explains instead of offering a dead button.
onAdd: containingSplitIDs.isEmpty ? nil : {
settingsEdit = SettingsEditRoute(initial: [], targetSplitIDs: containingSplitIDs)
onAdd: containingRoutineIDs.isEmpty ? nil : {
settingsEdit = SettingsEditRoute(initial: [], targetRoutineIDs: containingRoutineIDs)
}
)
}
@@ -165,10 +165,10 @@ struct ExerciseLibraryDetailView: View {
.onDisappear { services.speechAnnouncer.stop() }
.sheet(item: $settingsEdit) { route in
MachineSettingsSheet(exerciseName: exerciseName, initialSettings: route.initial) { updated in
let targets = route.targetSplitIDs
let targets = route.targetRoutineIDs
Task {
for splitID in targets {
await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, splitID: splitID)
for routineID in targets {
await sync.writeBackMachineSettings(updated, exerciseName: exerciseName, routineID: routineID)
}
}
}
@@ -176,29 +176,29 @@ struct ExerciseLibraryDetailView: View {
}
private func edit(group: SettingsGroup) {
settingsEdit = SettingsEditRoute(initial: group.settings, targetSplitIDs: group.splitIDs)
settingsEdit = SettingsEditRoute(initial: group.settings, targetRoutineIDs: group.routineIDs)
}
}
/// One distinct recorded settings list and the splits it came from (the edit targets).
/// One distinct recorded settings list and the routines it came from (the edit targets).
fileprivate struct SettingsGroup {
var label: String?
var settings: [MachineSetting]
var splitIDs: [String]
var routineIDs: [String]
}
/// Sheet route: the settings to seed the editor with and the splits a save writes to.
/// Sheet route: the settings to seed the editor with and the routines a save writes to.
private struct SettingsEditRoute: Identifiable {
let id = UUID()
var initial: [MachineSetting]
var targetSplitIDs: [String]
var targetRoutineIDs: [String]
}
/// The user's recorded machine comfort settings, set apart from the reference text
/// as a card: namevalue rows, grouped per split when the recorded lists differ.
/// as a card: namevalue rows, grouped per routine when the recorded lists differ.
/// Empty `groups` renders the not-yet-recorded state (shown for machine-based
/// exercises so the feature is visible before first use), with an Add button when
/// the exercise lives in at least one split (`onAdd` non-nil). A single group edits
/// the exercise lives in at least one routine (`onAdd` non-nil). A single group edits
/// from the header; disagreeing groups edit per group.
private struct MachineSettingsCard: View {
let groups: [SettingsGroup]
@@ -222,7 +222,7 @@ private struct MachineSettingsCard: View {
if groups.isEmpty {
Text(onAdd != nil
? "None recorded yet — save the seat, pad, and pin positions once and they'll be here next time."
: "None recorded yet. Add this exercise to a split, then record its settings here or from the workout screen.")
: "None recorded yet. Add this exercise to a routine, then record its settings here or from the workout screen.")
.font(.callout)
.foregroundStyle(.secondary)
}
+27 -27
View File
@@ -15,41 +15,41 @@ struct ExerciseListView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.dismiss) private var dismiss
// Resolve the split by id, not a captured entity: editing a seed's exercise from
// Resolve the routine by id, not a captured entity: editing a seed's exercise from
// here clones the seed (new identity, old entity deleted), which would dangle a
// stored `Split`. `currentSplitID` follows that swap.
@State private var splitID: String
@Query private var splits: [Split]
// stored `Routine`. `currentRoutineID` follows that swap.
@State private var routineID: String
@Query private var routines: [Routine]
@State private var showingAddSheet: Bool = false
@State private var itemToEdit: Exercise? = nil
@State private var itemToDelete: Exercise? = nil
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
init(split: Split) {
_splitID = State(initialValue: split.id)
init(routine: Routine) {
_routineID = State(initialValue: routine.id)
}
private var split: Split? {
let id = sync.currentSplitID(for: splitID)
return splits.first { $0.id == id }
private var routine: Routine? {
let id = sync.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
Group {
if let split {
content(for: split)
if let routine {
content(for: routine)
} else {
ContentUnavailableView("Split Unavailable", systemImage: "dumbbell")
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
.task { dismiss() }
}
}
}
@ViewBuilder
private func content(for split: Split) -> some View {
private func content(for routine: Routine) -> some View {
Form {
let sortedExercises = split.exercisesArray
let sortedExercises = routine.exercisesArray
if !sortedExercises.isEmpty {
ForEach(sortedExercises) { item in
@@ -86,14 +86,14 @@ struct ExerciseListView: View {
}
}
}
.navigationTitle(split.name)
.navigationTitle(routine.name)
.sheet(isPresented: $showingAddSheet) {
ExercisePickerView(onExerciseSelected: { exerciseNames in
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item, split: split)
ExerciseAddEditView(exercise: item, routine: routine)
}
.confirmationDialog(
"Delete Exercise?",
@@ -112,29 +112,29 @@ struct ExerciseListView: View {
itemToDelete = nil
}
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
Text("Remove \"\(item.name)\" from this routine?")
}
}
// MARK: - Helpers
private func moveExercises(from source: IndexSet, to destination: Int) {
guard let split else { return }
var exercises = split.exercisesArray
guard let routine else { return }
var exercises = routine.exercisesArray
exercises.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split)
var doc = RoutineDocument(from: routine)
doc.exercises = exercises.enumerated().map { i, ex in
var ed = ExerciseDocument(from: ex)
ed.order = i
return ed
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
}
private func addExercises(names: [String]) {
guard let split else { return }
var doc = SplitDocument(from: split)
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
@@ -154,17 +154,17 @@ struct ExerciseListView: View {
}
doc.exercises.append(contentsOf: newDocs)
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
}
private func deleteExercise(_ exercise: Exercise) {
guard let split else { return }
var doc = SplitDocument(from: split)
guard let routine else { return }
var doc = RoutineDocument(from: routine)
doc.exercises.removeAll { $0.id == exercise.id }
for i in doc.exercises.indices {
doc.exercises[i].order = i
}
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
}
}
@@ -0,0 +1,101 @@
//
// AchievementsSection.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// The achievements badge grid. Every badge is a pure derivation of history
/// (`ProgressPlanner.achievements`) earned ones glow in their color, locked ones
/// wear a progress ring showing how close they are. Tapping shows the how-to-earn
/// detail.
struct AchievementsSection: View {
let achievements: [Achievement]
@State private var selected: Achievement?
private var earnedCount: Int { achievements.filter(\.earned).count }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Achievements")
.font(.headline)
Spacer()
Text("\(earnedCount) of \(achievements.count)")
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
}
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4),
spacing: 16) {
ForEach(achievements) { achievement in
Button {
selected = achievement
} label: {
AchievementBadge(achievement: achievement)
}
.buttonStyle(.plain)
}
}
}
.progressCard()
.alert(
selected?.title ?? "",
isPresented: Binding(
get: { selected != nil },
set: { if !$0 { selected = nil } }
),
presenting: selected
) { _ in
Button("OK") { selected = nil }
} message: { achievement in
Text(achievement.earned
? achievement.detail
: "\(achievement.detail)\(Int((achievement.progress * 100).rounded()))% there.")
}
}
}
/// One badge: a tinted circle with the symbol, the title beneath. Locked badges are
/// desaturated with a thin progress arc around the circle.
struct AchievementBadge: View {
let achievement: Achievement
private var color: Color { Color.color(from: achievement.colorName) }
var body: some View {
VStack(spacing: 6) {
ZStack {
Circle()
.fill(achievement.earned ? color.opacity(0.18) : Color(.systemFill))
Image(systemName: achievement.systemImage)
.font(.title3)
.foregroundStyle(achievement.earned ? color : Color(.tertiaryLabel))
}
.frame(width: 52, height: 52)
.overlay {
if !achievement.earned, achievement.progress > 0 {
Circle()
.trim(from: 0, to: achievement.progress)
.stroke(color.opacity(0.6),
style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
}
}
Text(achievement.title)
.font(.caption2)
.multilineTextAlignment(.center)
.lineLimit(2, reservesSpace: true)
.foregroundStyle(achievement.earned ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityLabel(
"\(achievement.title), \(achievement.earned ? "earned" : "locked"): \(achievement.detail)")
}
}
@@ -0,0 +1,144 @@
//
// ExerciseTrendsView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// Per-exercise drill-down: every weighted exercise with completed history, most
/// recently trained first, each opening its weight-progression detail. This promotes
/// the progression chart to a first-class Progress surface (it also remains on the
/// library's reference pages).
struct ExerciseTrendsListView: View {
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Completed weighted logs, newest first.
@Query private var logs: [WorkoutLog]
init() {
let completedRaw = WorkoutStatus.completed.rawValue
let weightRaw = LoadType.weight.rawValue
_logs = Query(
filter: #Predicate<WorkoutLog> {
$0.statusRaw == completedRaw && $0.loadType == weightRaw
},
sort: \WorkoutLog.date,
order: .reverse)
}
private struct ExerciseSummary: Identifiable {
var name: String
var sessions: Int
var best: Double
var last: Date
var id: String { name }
}
/// One row per exercise name: session count, best top-set weight, last trained.
/// `logs` is newest-first, so the first log seen per name fixes `last`.
private var summaries: [ExerciseSummary] {
var byName: [String: ExerciseSummary] = [:]
for log in logs {
let top = log.setEntries?.compactMap(\.weight).max() ?? log.weight
if var summary = byName[log.exerciseName] {
summary.sessions += 1
summary.best = max(summary.best, top)
byName[log.exerciseName] = summary
} else {
byName[log.exerciseName] = ExerciseSummary(
name: log.exerciseName, sessions: 1, best: top, last: log.date)
}
}
return byName.values.sorted { $0.last > $1.last }
}
var body: some View {
List(summaries) { summary in
NavigationLink {
ExerciseTrendDetailView(exerciseName: summary.name)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(summary.name)
Text("\(summary.sessions) session\(summary.sessions == 1 ? "" : "s") · last \(summary.last.daysAgoLabel())")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(weightUnit.format(summary.best))
.font(.subheadline.weight(.semibold))
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
}
.overlay {
if summaries.isEmpty {
ContentUnavailableView(
"No Weighted Exercises Yet",
systemImage: "dumbbell",
description: Text("Complete weighted sets and each exercise's progression shows up here."))
}
}
.navigationTitle("Exercise Trends")
}
}
/// One exercise's progression: headline stats over its completed history plus the
/// weight-progression chart.
struct ExerciseTrendDetailView: View {
let exerciseName: String
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Completed logs for this exercise, oldest first (matching the chart's order).
@Query private var logs: [WorkoutLog]
init(exerciseName: String) {
self.exerciseName = exerciseName
let name = exerciseName
let completedRaw = WorkoutStatus.completed.rawValue
_logs = Query(
filter: #Predicate<WorkoutLog> {
$0.exerciseName == name && $0.statusRaw == completedRaw
},
sort: \WorkoutLog.date,
order: .forward)
}
private var bestWeight: Double {
logs.map { $0.setEntries?.compactMap(\.weight).max() ?? $0.weight }.max() ?? 0
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(spacing: 12) {
ProgressStatTile(
title: "Best",
value: weightUnit.format(bestWeight),
unit: nil)
ProgressStatTile(
title: "Sessions",
value: "\(logs.count)",
unit: nil)
ProgressStatTile(
title: "Last",
value: logs.last?.date.daysAgoLabel() ?? "",
unit: nil)
}
WeightProgressionChartView(exerciseName: exerciseName)
.progressCard()
}
.padding(.horizontal)
}
.background(Color(.systemGroupedBackground))
.navigationTitle(exerciseName)
.navigationBarTitleDisplayMode(.inline)
}
}
+138
View File
@@ -0,0 +1,138 @@
//
// GoalTrackCard.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
/// One goal's adherence card: a 12-week strip (each square filled by that week's
/// met/expected fraction), the weekly streak flame, and a this-week dot row per
/// member schedule. The goal is the ledger category this track continues unbroken
/// across schedule rewrites.
struct GoalTrackCard: View {
let track: GoalTrack
private var color: Color {
track.goal.map { Color.color(from: $0.colorName) } ?? Color(.systemGray)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label(track.goal?.displayName ?? "Unassigned",
systemImage: track.goal?.systemImage ?? "tag")
.font(.headline)
.foregroundStyle(color)
Spacer()
if track.streakWeeks > 0 {
streakBadge
}
}
weekStrip
VStack(alignment: .leading, spacing: 10) {
ForEach(track.scheduleTracks, id: \.schedule.id) { scheduleTrack in
scheduleRow(scheduleTrack)
}
}
}
.progressCard()
}
/// The streak flame: orange while the current week is still meetable, dimmed the
/// moment it can no longer be (the streak is at stake, not yet erased).
private var streakBadge: some View {
HStack(spacing: 3) {
Image(systemName: "flame.fill")
Text("\(track.streakWeeks) wk")
.monospacedDigit()
}
.font(.subheadline.weight(.semibold))
.foregroundStyle(track.streakAlive ? Color.orange : Color.secondary)
.accessibilityLabel("\(track.streakWeeks)-week streak\(track.streakAlive ? "" : ", at risk")")
}
private var weekStrip: some View {
HStack(spacing: 4) {
ForEach(Array(track.weeks.enumerated()), id: \.offset) { index, week in
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(fill(for: week))
.frame(height: 16)
.overlay {
// Outline the in-flight week so "unfilled" reads as "not over
// yet" rather than "missed".
if index == track.weeks.count - 1 {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.strokeBorder(color.opacity(0.8), lineWidth: 1.5)
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(stripSummary)
}
private func fill(for week: WeekMark) -> Color {
if week.isVacuous { return Color(.systemFill).opacity(0.4) }
if week.fraction == 0 { return Color(.systemFill) }
return color.opacity(0.25 + 0.75 * week.fraction)
}
private var stripSummary: String {
let hit = track.weeks.filter { !$0.isVacuous && $0.hit }.count
let active = track.weeks.filter { !$0.isVacuous }.count
return "Hit \(hit) of the last \(active) weeks"
}
private func scheduleRow(_ scheduleTrack: ScheduleTrack) -> some View {
let schedule = scheduleTrack.schedule
return HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text(schedule.routineName)
.font(.subheadline)
Text(schedule.recurrence.summary(
weekdays: schedule.weekdays,
timesPerWeek: schedule.timesPerWeek,
date: schedule.onceDate))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
thisWeekDots(scheduleTrack)
}
}
/// This week for one schedule: `expected` dots, `met` of them filled. A broken
/// week adds a quiet "missed" tag informative, not scolding.
@ViewBuilder
private func thisWeekDots(_ scheduleTrack: ScheduleTrack) -> some View {
if let week = scheduleTrack.currentWeek, !week.isVacuous {
HStack(spacing: 5) {
if scheduleTrack.currentWeekBroken {
Text("missed")
.font(.caption2)
.foregroundStyle(.secondary)
}
ForEach(0..<week.expected, id: \.self) { index in
Circle()
.fill(index < week.met ? color : Color.clear)
.overlay {
if index >= week.met {
Circle().strokeBorder(color.opacity(0.4), lineWidth: 1.5)
}
}
.frame(width: 9, height: 9)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(week.met) of \(week.expected) this week")
} else {
Text("")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
@@ -0,0 +1,314 @@
//
// ProgressTabView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// The Progress tab observe by goals (UX-REDESIGN.md). Per-goal adherence tracks
/// first, then this-week totals, aggregate trends, per-exercise drill-down, the
/// achievements grid, and full history (absorbed from the old Log tab). Everything on
/// this screen is derived by `ProgressPlanner` from the workout documents; nothing is
/// persisted, so it all survives cache rebuilds.
struct ProgressTabView: View {
/// Screenshot-capture affordance: `.bottom` opens the tab pre-scrolled to the
/// end (nil = the normal top). Only the DEBUG screenshot root passes it.
var initialAnchor: UnitPoint?
@Environment(SyncEngine.self) private var sync
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
private var schedules: [Schedule]
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
var body: some View {
NavigationStack {
ScrollView {
let facts = workoutFacts
let tracks = ProgressPlanner.goalTracks(
schedules: scheduleFacts, workouts: facts,
goalOrder: GoalKind.orderedCases())
VStack(alignment: .leading, spacing: 16) {
WeekSummaryTiles(workouts: facts)
if tracks.isEmpty {
goalTracksHint
} else {
ForEach(tracks) { GoalTrackCard(track: $0) }
}
TrendsSection(workouts: facts)
exerciseTrendsLink
AchievementsSection(
achievements: ProgressPlanner.achievements(tracks: tracks, workouts: facts))
historySection
}
.padding(.horizontal)
.padding(.bottom, 24)
}
.defaultScrollAnchor(initialAnchor)
.background(Color(.systemGroupedBackground))
.navigationTitle("Progress")
}
}
// MARK: - Fact building (live-resolve IDs through the seed clone-on-edit redirect)
private var scheduleFacts: [ScheduleFacts] {
schedules.map { schedule in
ScheduleFacts(
id: schedule.id,
goal: schedule.goalKind,
recurrence: schedule.recurrenceEnum,
weekdays: schedule.weekdays,
timesPerWeek: schedule.timesPerWeek,
onceDate: schedule.date,
createdAt: schedule.createdAt,
routineID: sync.currentRoutineID(for: schedule.routineID),
routineName: schedule.routineName)
}
}
private var workoutFacts: [WorkoutFacts] {
workouts.map { workout in
WorkoutFacts(
routineID: workout.routineID.map { sync.currentRoutineID(for: $0) },
start: workout.start,
end: workout.end,
completed: workout.status == .completed,
volume: Self.volume(of: workout),
activeMinutes: workout.end.map { max(0, $0.timeIntervalSince(workout.start) / 60) } ?? 0,
energyKcal: workout.metricActiveEnergyKcal ?? 0)
}
}
/// Total lifted volume for one workout: the recorded watch metric when present,
/// else derived from per-set actuals, else the plan (the same fallback ladder the
/// documents' `effectiveSetEntries` uses).
static func volume(of workout: Workout) -> Double {
if let recorded = workout.metricTotalVolume { return recorded }
return workout.logs.reduce(0) { sum, log in
guard log.status == .completed, log.loadTypeEnum == .weight else { return sum }
if let entries = log.setEntries, !entries.isEmpty {
return sum + entries.reduce(0) { $0 + Double($1.reps ?? 0) * ($1.weight ?? 0) }
}
return sum + Double(log.sets * log.reps) * log.weight
}
}
// MARK: - Sections
private var goalTracksHint: some View {
VStack(alignment: .leading, spacing: 6) {
Label("No Goal Tracks Yet", systemImage: "chart.bar.fill")
.font(.headline)
Text("Add schedules on the Today tab and each goal builds a weekly adherence track here.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.progressCard()
}
private var exerciseTrendsLink: some View {
NavigationLink {
ExerciseTrendsListView()
} label: {
HStack(spacing: 12) {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.title3)
.foregroundStyle(.blue)
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text("Exercise Trends")
.font(.headline)
.foregroundStyle(.primary)
Text("Weight progression for every exercise you've logged")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
.progressCard()
}
.buttonStyle(.plain)
}
private var historySection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("History")
.font(.headline)
Spacer()
NavigationLink("All Workouts") {
WorkoutHistoryView()
}
.font(.subheadline)
}
if workouts.isEmpty {
Text("No workouts yet. Start one from the Today tab.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
ForEach(workouts.prefix(3)) { workout in
NavigationLink {
WorkoutLogListView(workout: workout)
} label: {
HStack {
CalendarListItem(
date: workout.start,
title: workout.routineName ?? Routine.unnamed,
subtitle: subtitle(for: workout),
subtitle2: workout.statusName)
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
.progressCard()
}
private func subtitle(for workout: Workout) -> String {
if workout.status == .completed, let endDate = workout.end {
return workout.start.humanTimeInterval(to: endDate)
}
return workout.start.formattedDate()
}
}
// MARK: - Shared card chrome
extension View {
/// The Progress tab's card treatment one shared background so every section
/// reads as part of the same system.
func progressCard() -> some View {
padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
// MARK: - This-week stat tiles
/// Three at-a-glance totals for the current calendar week, each with a delta arrow
/// against last week.
struct WeekSummaryTiles: View {
let workouts: [WorkoutFacts]
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
var body: some View {
let calendar = Calendar.current
let now = Date()
let this = totals(in: calendar.dateInterval(of: .weekOfYear, for: now))
let last = totals(in: calendar.date(byAdding: .weekOfYear, value: -1, to: now)
.flatMap { calendar.dateInterval(of: .weekOfYear, for: $0) })
HStack(spacing: 12) {
ProgressStatTile(
title: "This Week",
value: "\(this.workouts)",
unit: this.workouts == 1 ? "workout" : "workouts",
delta: Double(this.workouts - last.workouts),
deltaText: "\(abs(this.workouts - last.workouts))")
ProgressStatTile(
title: "Volume",
value: this.volume.formatted(.number.notation(.compactName).precision(.significantDigits(3))),
unit: weightUnit.abbreviation,
delta: this.volume - last.volume,
deltaText: abs(this.volume - last.volume)
.formatted(.number.notation(.compactName).precision(.significantDigits(2))))
ProgressStatTile(
title: "Active Time",
value: Self.hoursMinutes(this.minutes),
unit: nil,
delta: this.minutes - last.minutes,
deltaText: Self.hoursMinutes(abs(this.minutes - last.minutes)))
}
}
private func totals(in interval: DateInterval?) -> (workouts: Int, volume: Double, minutes: Double) {
guard let interval else { return (0, 0, 0) }
let matching = workouts.filter { $0.completed && interval.contains($0.start) }
return (matching.count,
matching.reduce(0) { $0 + $1.volume },
matching.reduce(0) { $0 + $1.activeMinutes })
}
static func hoursMinutes(_ minutes: Double) -> String {
let total = Int(minutes.rounded())
let (h, m) = (total / 60, total % 60)
return h > 0 ? "\(h)h \(m)m" : "\(m)m"
}
}
/// One compact stat tile: caption, big value, and a vs-last-week arrow. Shared by the
/// week summary row and the exercise drill-down.
struct ProgressStatTile: View {
let title: String
let value: String
var unit: String?
/// Signed change vs the comparison period; only its sign is displayed.
var delta: Double?
/// Preformatted magnitude for the delta line (callers know their unit best).
var deltaText: String?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .firstTextBaseline, spacing: 3) {
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.7)
if let unit {
Text(unit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
if let delta, delta != 0 {
Label("\(deltaText ?? fallbackDeltaText(delta)) vs last wk",
systemImage: delta > 0 ? "arrow.up" : "arrow.down")
.font(.caption2.weight(.medium))
.foregroundStyle(delta > 0 ? .green : .secondary)
} else {
// Keep tile heights equal whether or not a delta renders.
Text("")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
Color(.secondarySystemGroupedBackground),
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
private func fallbackDeltaText(_ delta: Double) -> String {
abs(delta).formatted(.number.notation(.compactName).precision(.significantDigits(2)))
}
}
+134
View File
@@ -0,0 +1,134 @@
//
// TrendsSection.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import Charts
/// Aggregate trends over time: one chart, a metric picker (workouts, volume, active
/// time, energy), and the standard 7D/30D/90D/1Y/All range picker. Counts render as
/// bars (discrete events); the other metrics as the smooth line + points pattern.
struct TrendsSection: View {
let workouts: [WorkoutFacts]
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
@State private var range: TrendRange = .month
@State private var metric: Metric = .workouts
enum Metric: String, CaseIterable, Identifiable {
case workouts = "Workouts"
case volume = "Volume"
case time = "Time"
case energy = "Energy"
var id: String { rawValue }
var color: Color {
switch self {
case .workouts: .blue
case .volume: .purple
case .time: .green
case .energy: .orange
}
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Trends")
.font(.headline)
Picker("Metric", selection: $metric) {
ForEach(Metric.allCases) { metric in
Text(metric.rawValue).tag(metric)
}
}
.pickerStyle(.segmented)
Picker("Range", selection: $range) {
ForEach(TrendRange.allCases) { range in
Text(range.rawValue).tag(range)
}
}
.pickerStyle(.segmented)
chart
}
.progressCard()
}
@ViewBuilder
private var chart: some View {
let points = ProgressPlanner.trendPoints(workouts: workouts, range: range)
if points.isEmpty || points.allSatisfy({ $0.workouts == 0 }) {
ContentUnavailableView(
"No Data",
systemImage: "chart.xyaxis.line",
description: Text(workouts.contains(where: \.completed)
? "No workouts in this time range. Try a wider range."
: "Complete workouts and your trends build up here."))
.frame(height: 220)
} else {
Chart(points) { point in
if metric == .workouts {
BarMark(
x: .value("Date", point.date, unit: range.bucket),
y: .value(metric.rawValue, value(of: point))
)
.foregroundStyle(metric.color.gradient)
.cornerRadius(3)
} else {
LineMark(
x: .value("Date", point.date, unit: range.bucket),
y: .value(metric.rawValue, value(of: point))
)
.interpolationMethod(.catmullRom)
.foregroundStyle(metric.color.gradient)
PointMark(
x: .value("Date", point.date, unit: range.bucket),
y: .value(metric.rawValue, value(of: point))
)
.symbolSize(30)
.foregroundStyle(metric.color)
}
}
.chartYAxisLabel(unitLabel)
.chartXAxis {
AxisMarks(values: .automatic) { _ in
AxisGridLine()
AxisValueLabel(format: axisDateFormat)
}
}
.frame(height: 220)
}
}
private func value(of point: TrendPoint) -> Double {
switch metric {
case .workouts: Double(point.workouts)
case .volume: point.volume
case .time: point.activeMinutes
case .energy: point.energyKcal
}
}
private var unitLabel: String {
switch metric {
case .workouts: ""
case .volume: weightUnit.abbreviation
case .time: "min"
case .energy: "kcal"
}
}
private var axisDateFormat: Date.FormatStyle {
switch range.bucket {
case .month: .dateTime.month(.abbreviated)
default: .dateTime.month().day()
}
}
}
@@ -1,5 +1,5 @@
//
// SplitAddEditView.swift
// RoutineAddEditView.swift
// Workouts
//
// Created by rzen on 7/18/25 at 9:42 AM.
@@ -11,17 +11,17 @@ import IndieSync
import SwiftUI
import SwiftData
struct SplitAddEditView: View {
struct RoutineAddEditView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
// Resolve the split by id, not a captured entity: a clone-on-edit swaps a seed's
// 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 `Split`. `currentSplitID` follows that swap. `splitID` is
// would dangle a stored `Routine`. `currentRoutineID` follows that swap. `routineID` is
// nil in create mode.
@State private var splitID: String?
@Query private var splits: [Split]
@State private var routineID: String?
@Query private var routines: [Routine]
var onDelete: (() -> Void)?
@@ -35,43 +35,43 @@ struct SplitAddEditView: View {
@State private var showingIconPicker: Bool = false
@State private var showingDeleteConfirmation: Bool = false
var isEditing: Bool { splitID != nil }
var isEditing: Bool { routineID != nil }
init(split: Split?, onDelete: (() -> Void)? = nil) {
_splitID = State(initialValue: split?.id)
init(routine: Routine?, onDelete: (() -> Void)? = nil) {
_routineID = State(initialValue: routine?.id)
self.onDelete = onDelete
if let split = split {
_name = State(initialValue: split.name)
_color = State(initialValue: split.color)
_systemImage = State(initialValue: split.systemImage)
_activityType = State(initialValue: split.activityTypeEnum)
_autoAdvance = State(initialValue: split.autoAdvance ?? false)
_restOverrideEnabled = State(initialValue: split.restSeconds != nil)
_restSecondsValue = State(initialValue: split.restSeconds ?? 45)
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 split: Split? {
guard let splitID else { return nil }
let id = sync.currentSplitID(for: splitID)
return splits.first { $0.id == id }
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 && split == nil {
// The id we held no longer maps to a live split (deleted on another
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("Split Unavailable", systemImage: "dumbbell")
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
.task { dismiss() }
} else {
form(for: split)
form(for: routine)
}
}
}
@ViewBuilder
private func form(for split: Split?) -> some View {
private func form(for routine: Routine?) -> some View {
Form {
Section(header: Text("Name")) {
TextField("Name", text: $name)
@@ -133,29 +133,29 @@ struct SplitAddEditView: View {
} 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 split hands-free. Custom Rest Time sets this split's rest — used between sets, and between exercises when auto-advancing; otherwise the Settings default applies.")
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 split = split {
if let routine = routine {
Section(header: Text("Exercises")) {
NavigationLink {
ExerciseListView(split: split)
ExerciseListView(routine: routine)
} label: {
ListItem(
text: "Exercises",
count: split.exercisesArray.count
count: routine.exercisesArray.count
)
}
}
Section {
Button("Delete Split", role: .destructive) {
Button("Delete Routine", role: .destructive) {
showingDeleteConfirmation = true
}
}
}
}
.navigationTitle(isEditing ? "Edit Split" : "New Split")
.navigationTitle(isEditing ? "Edit Routine" : "New Routine")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
@@ -175,30 +175,30 @@ struct SplitAddEditView: View {
SFSymbolPicker(selection: $systemImage)
}
.confirmationDialog(
"Delete This Split?",
"Delete This Routine?",
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let split = split {
if let routine = routine {
Task {
await sync.delete(split: split)
await sync.delete(routine: routine)
}
dismiss()
onDelete?()
}
}
} message: {
Text("This will permanently delete the split and all its exercises.")
Text("This will permanently delete the routine and all its exercises.")
}
}
private func save() {
if isEditing {
// Update existing split. If the id no longer resolves (deleted remotely
// Update existing routine. If the id no longer resolves (deleted remotely
// mid-edit), there's nothing to save.
guard let split = split else { return }
var doc = SplitDocument(from: split)
guard let routine = routine else { return }
var doc = RoutineDocument(from: routine)
doc.name = name
doc.color = color
doc.systemImage = systemImage
@@ -206,12 +206,12 @@ struct SplitAddEditView: View {
doc.restSeconds = restOverrideEnabled ? restSecondsValue : nil
doc.autoAdvance = autoAdvance ? true : nil
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
} else {
// Create new split
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
let doc = SplitDocument(
schemaVersion: SplitDocument.currentSchemaVersion,
// Create new routine
let existing = (try? modelContext.fetch(FetchDescriptor<Routine>())) ?? []
let doc = RoutineDocument(
schemaVersion: RoutineDocument.currentSchemaVersion,
id: ULID.make(),
name: name,
color: color,
@@ -224,7 +224,7 @@ struct SplitAddEditView: View {
restSeconds: restOverrideEnabled ? restSecondsValue : nil,
autoAdvance: autoAdvance ? true : nil
)
Task { await sync.save(split: doc) }
Task { await sync.save(routine: doc) }
}
}
}
@@ -1,5 +1,5 @@
//
// SplitDetailView.swift
// RoutineDetailView.swift
// Workouts
//
// Created by rzen on 7/25/25 at 3:27 PM.
@@ -11,70 +11,70 @@ import IndieSync
import SwiftUI
import SwiftData
struct SplitDetailView: View {
struct RoutineDetailView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Environment(\.dismiss) private var dismiss
// Resolve the split by id, not a captured entity: a clone-on-edit swaps a seed's
// 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 `Split`. `currentSplitID` follows that swap.
@State private var splitID: String
@Query private var splits: [Split]
// 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 showingSplitEditSheet: 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(split: Split) {
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 split
// 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 split, so
// `body` shows the "Split Unavailable" state and dismisses; the row is on its
// 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 = !split.isDeleted && split.modelContext != nil
_splitID = State(initialValue: live ? split.id : "")
let live = !routine.isDeleted && routine.modelContext != nil
_routineID = State(initialValue: live ? routine.id : "")
}
private var split: Split? {
let id = sync.currentSplitID(for: splitID)
return splits.first { $0.id == id }
private var routine: Routine? {
let id = sync.currentRoutineID(for: routineID)
return routines.first { $0.id == id }
}
var body: some View {
Group {
if let split {
content(for: split)
if let routine {
content(for: routine)
} else {
// The id we held no longer maps to a live split (deleted on another
// 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("Split Unavailable", systemImage: "dumbbell")
ContentUnavailableView("Routine Unavailable", systemImage: "dumbbell")
.task { dismiss() }
}
}
// Editing this split (or any of its exercises, all reached from here) parks any
// active watch run sourced from it matched by splitID so the watch can't keep
// 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.setEditingSplit(sync.currentSplitID(for: splitID)) }
.onDisappear { services.watchBridge.setEditingSplit(nil) }
.onAppear { services.watchBridge.setEditingRoutine(sync.currentRoutineID(for: routineID)) }
.onDisappear { services.watchBridge.setEditingRoutine(nil) }
}
@ViewBuilder
private func content(for split: Split) -> some View {
private func content(for routine: Routine) -> some View {
Form {
Section(header: Text("What is a Split?")) {
Text("A \"split\" is simply how you divide (or \"split 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.")
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 split.exercisesArray.isEmpty {
if routine.exercisesArray.isEmpty {
Section {
Text("No exercises added yet.")
Button(action: { showingExerciseAddSheet.toggle() }) {
@@ -83,7 +83,7 @@ struct SplitDetailView: View {
}
} else {
Section {
ForEach(split.exercisesArray) { item in
ForEach(routine.exercisesArray) { item in
ListItem(
title: item.name,
subtitle: item.planSummary(weightUnit: weightUnit)
@@ -115,11 +115,11 @@ struct SplitDetailView: View {
}
}
}
.navigationTitle(split.name)
.navigationTitle(routine.name)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingSplitEditSheet = true
showingRoutineEditSheet = true
} label: {
Image(systemName: "pencil")
}
@@ -130,13 +130,13 @@ struct SplitDetailView: View {
addExercises(names: exerciseNames)
}, allowMultiSelect: true)
}
.sheet(isPresented: $showingSplitEditSheet) {
SplitAddEditView(split: split) {
.sheet(isPresented: $showingRoutineEditSheet) {
RoutineAddEditView(routine: routine) {
dismiss()
}
}
.sheet(item: $itemToEdit) { item in
ExerciseAddEditView(exercise: item, split: split)
ExerciseAddEditView(exercise: item, routine: routine)
}
.confirmationDialog(
"Delete Exercise?",
@@ -155,30 +155,30 @@ struct SplitDetailView: View {
itemToDelete = nil
}
} message: { item in
Text("Remove \"\(item.name)\" from this split?")
Text("Remove \"\(item.name)\" from this routine?")
}
}
/// Reorder and renumber. Resolves the current split at call time so it
/// 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 split else { return }
var ordered = split.exercisesArray
guard let routine else { return }
var ordered = routine.exercisesArray
ordered.move(fromOffsets: source, toOffset: destination)
var doc = SplitDocument(from: split)
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(split: doc) }
Task { await sync.save(routine: doc) }
}
private func addExercises(names: [String]) {
guard let split else { return }
var doc = SplitDocument(from: split)
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
@@ -198,21 +198,21 @@ struct SplitDetailView: View {
}
doc.exercises.append(contentsOf: newDocs)
doc.updatedAt = Date()
Task { await sync.save(split: doc) }
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 split else { return }
var doc = SplitDocument(from: split)
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(split: doc) }
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)
}
}
@@ -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)
}
}
+21 -10
View File
@@ -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.")
}
}
-118
View File
@@ -1,118 +0,0 @@
//
// SplitListView.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 Splits library screen, pushed from Settings Library: a plain list of
/// splits, one row each. Tapping a row 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
@Query(sort: \Split.name) private var splits: [Split]
@State private var showingAddSheet = false
@State private var splitToDelete: Split?
var body: some View {
List {
ForEach(splits) { split in
NavigationLink {
SplitDetailView(split: split)
} label: {
SplitRow(split: split)
}
.contextMenu {
Button(role: .destructive) {
splitToDelete = split
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.insetGrouped)
.overlay {
if splits.isEmpty {
ContentUnavailableView(
"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.")
}
}
}
/// A single split row: a small rounded-square badge (the split's color at low
/// opacity, its symbol tinted full-strength) beside the split name and its
/// exercise count.
private struct SplitRow: View {
var split: Split
var body: some View {
HStack(spacing: 12) {
Image(systemName: split.systemImage)
.font(.title3)
.foregroundStyle(splitColor)
.frame(width: 36, height: 36)
.background(splitColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 2) {
Text(split.name)
.foregroundStyle(.primary)
Text("\(split.exercisesArray.count) exercises")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
private var splitColor: Color {
Color.color(from: split.color)
}
}
+98
View File
@@ -0,0 +1,98 @@
//
// DayPickerSheet.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import UIKit
/// The calendar toolbar button's sheet: jumps the Today board to any day. Days with
/// at least one logged workout carry a small dot. Built on `UICalendarView` rather
/// than the SwiftUI graphical `DatePicker` solely because only the UIKit calendar
/// supports per-day decorations. Picking a day (or "Today") dismisses immediately
/// this is navigation, not a form, so there's nothing to confirm.
struct DayPickerSheet: View {
@Binding var selectedDate: Date
/// Day-precision (year/month/day) components of the days to decorate.
let workoutDays: Set<DateComponents>
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
DayCalendarView(selectedDate: $selectedDate, workoutDays: workoutDays) {
dismiss()
}
.padding(.horizontal)
.navigationTitle("Go to Day")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Today") {
selectedDate = Date()
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
}
/// `UICalendarView` wrapper: single-date selection plus a dot decoration on each
/// day in `workoutDays`.
private struct DayCalendarView: UIViewRepresentable {
@Binding var selectedDate: Date
let workoutDays: Set<DateComponents>
/// Called after a day is picked the sheet dismisses itself here.
var onPick: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UICalendarView {
let view = UICalendarView()
view.calendar = Calendar.current
view.delegate = context.coordinator
let selection = UICalendarSelectionSingleDate(delegate: context.coordinator)
selection.setSelected(
Calendar.current.dateComponents([.year, .month, .day], from: selectedDate),
animated: false
)
view.selectionBehavior = selection
return view
}
func updateUIView(_ uiView: UICalendarView, context: Context) {
context.coordinator.parent = self
}
@MainActor
final class Coordinator: NSObject, UICalendarViewDelegate, UICalendarSelectionSingleDateDelegate {
var parent: DayCalendarView
init(_ parent: DayCalendarView) { self.parent = parent }
func calendarView(_ calendarView: UICalendarView,
decorationFor dateComponents: DateComponents) -> UICalendarView.Decoration? {
// The view hands back components richer than year/month/day (era, calendar,
// ) reduce to day precision so set membership compares equal.
var day = DateComponents()
day.year = dateComponents.year
day.month = dateComponents.month
day.day = dateComponents.day
return parent.workoutDays.contains(day) ? .default(color: .systemGreen, size: .small) : nil
}
func dateSelection(_ selection: UICalendarSelectionSingleDate,
didSelectDate dateComponents: DateComponents?) {
guard let dateComponents, let date = Calendar.current.date(from: dateComponents) else { return }
parent.selectedDate = date
parent.onPick()
}
}
}
@@ -0,0 +1,200 @@
//
// ScheduleAddEditView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
/// Add / edit a schedule: pick a routine, tag it with a goal, and set how often it
/// recurs. Fields are snapshotted in `init` so the sheet never holds the live entity.
struct ScheduleAddEditView: View {
@Environment(SyncEngine.self) private var sync
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
/// nil id add mode; a value edit mode (id / order / createdAt preserved).
@State private var scheduleID: String?
@State private var order: Int
@State private var createdAt: Date
@State private var routineID: String
/// The schedule's routine name when it was created surfaced when that routine
/// has since been deleted so the user knows what they're reassigning.
@State private var originalRoutineName: String
@State private var goal: GoalKind?
@State private var recurrence: ScheduleRecurrence
@State private var weekdays: Set<Int>
@State private var timesPerWeek: Int
@State private var date: Date
/// Calendar weekday numbers in Monday-first display order (MonSat = 27, Sun = 1).
private let weekdayOrder = [2, 3, 4, 5, 6, 7, 1]
/// Gregorian short names, 0-indexed from Sunday weekday `n` is `symbols[n - 1]`.
private let weekdaySymbols = Calendar(identifier: .gregorian).shortWeekdaySymbols
/// `defaultDate` seeds the `.once` day when adding the Today board passes the day
/// currently on screen, so "navigate to a day, tap +" plans a one-off for that day.
init(schedule: Schedule? = nil, defaultDate: Date = Date()) {
_scheduleID = State(initialValue: schedule?.id)
_order = State(initialValue: schedule?.order ?? 0)
_createdAt = State(initialValue: schedule?.createdAt ?? Date())
_routineID = State(initialValue: schedule?.routineID ?? "")
_originalRoutineName = State(initialValue: schedule?.routineName ?? "")
_goal = State(initialValue: schedule?.goalKind)
_recurrence = State(initialValue: schedule?.recurrenceEnum ?? .once)
_weekdays = State(initialValue: Set(schedule?.weekdays ?? []))
_timesPerWeek = State(initialValue: schedule?.timesPerWeek ?? 3)
_date = State(initialValue: schedule?.date ?? defaultDate)
}
private var isEditing: Bool { scheduleID != nil }
/// The live routine currently selected; nil when the stored id no longer resolves.
private var selectedRoutine: Routine? { routines.first { $0.id == routineID } }
private var canSave: Bool {
guard selectedRoutine != nil else { return false }
if recurrence == .fixedDays { return !weekdays.isEmpty }
return true
}
var body: some View {
NavigationStack {
Form {
routineSection
goalSection
recurrenceSection
}
.navigationTitle(isEditing ? "Edit Schedule" : "New Schedule")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Save", action: save).disabled(!canSave)
}
}
.onAppear {
// Default to the first routine when adding (the @Query isn't populated at init).
if routineID.isEmpty { routineID = routines.first?.id ?? "" }
}
}
}
// MARK: - Sections
private var routineSection: some View {
Section {
Picker("Routine", selection: $routineID) {
ForEach(routines) { routine in
Text(routine.name).tag(routine.id)
}
}
} header: {
Text("Routine")
} footer: {
if selectedRoutine == nil, !originalRoutineName.isEmpty {
Text("\"\(originalRoutineName)\" is no longer available. Choose a routine.")
}
}
}
private var goalSection: some View {
Section {
Picker("Goal", selection: $goal) {
Text("None").tag(GoalKind?.none)
ForEach(GoalKind.orderedCases()) { kind in
Label(kind.displayName, systemImage: kind.systemImage)
.foregroundStyle(Color.color(from: kind.colorName))
.tag(GoalKind?.some(kind))
}
}
.pickerStyle(.inline)
.labelsHidden()
} header: {
Text("Goal")
}
}
@ViewBuilder
private var recurrenceSection: some View {
Section {
Picker("Repeat", selection: $recurrence) {
ForEach(ScheduleRecurrence.allCases, id: \.self) { r in
Text(r.displayName).tag(r)
}
}
switch recurrence {
case .once:
DatePicker("Date", selection: $date, displayedComponents: .date)
case .daily:
EmptyView()
case .fixedDays:
weekdayPicker
case .frequency:
Stepper(value: $timesPerWeek, in: 1...7) {
Text("\(timesPerWeek)× per week")
}
}
} header: {
Text("Repeat")
}
}
/// A row of toggleable weekday chips, Monday-first.
private var weekdayPicker: some View {
HStack(spacing: 6) {
ForEach(weekdayOrder, id: \.self) { n in
let on = weekdays.contains(n)
Button {
if on { weekdays.remove(n) } else { weekdays.insert(n) }
} label: {
Text(weekdaySymbols[n - 1])
.font(.caption.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(on ? Color.accentColor : Color.secondary.opacity(0.15), in: Capsule())
.foregroundStyle(on ? Color.white : Color.primary)
}
.buttonStyle(.plain)
}
}
.padding(.vertical, 4)
}
// MARK: - Save
private func save() {
guard let routine = selectedRoutine else { return }
let doc = ScheduleDocument(
schemaVersion: ScheduleDocument.currentSchemaVersion,
id: scheduleID ?? ULID.make(),
routineID: routine.id,
routineName: routine.name,
goal: goal?.rawValue,
recurrence: recurrence.rawValue,
weekdays: recurrence == .fixedDays ? weekdays.sorted() : nil,
timesPerWeek: recurrence == .frequency ? timesPerWeek : nil,
date: recurrence == .once ? date : nil,
order: isEditing ? order : nextOrder(),
createdAt: createdAt,
updatedAt: Date()
)
Task { await sync.save(schedule: doc) }
dismiss()
}
/// One past the highest existing schedule order appends a new schedule to the end.
private func nextOrder() -> Int {
let existing = (try? modelContext.fetch(FetchDescriptor<Schedule>())) ?? []
return (existing.map(\.order).max() ?? -1) + 1
}
}
+293
View File
@@ -0,0 +1,293 @@
//
// TodayView.swift
// Workouts
//
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// The Today board the today-projection of the user's schedules, grouped by goal.
/// A schedule row shows its routine, its recurrence, and today's status for that
/// routine; tapping starts (or opens) that routine's workout for today.
struct TodayView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Query(sort: [SortDescriptor(\Schedule.order), SortDescriptor(\Schedule.createdAt)])
private var schedules: [Schedule]
@Query(sort: [SortDescriptor(\Routine.order), SortDescriptor(\Routine.name)])
private var routines: [Routine]
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
/// ID of the workout to push into a just-started one (the navigator polls for
/// its cache entity) or an existing today-workout (resolves immediately). One
/// id-based path for both, so no destination ever retains a `@Model`.
@State private var pendingWorkoutID: String?
@State private var scheduleToEdit: Schedule?
@State private var showingAddSchedule = false
@State private var showingDayPicker = false
/// The day the board is projecting today by default; the calendar toolbar
/// button navigates to any other day.
@State private var selectedDate = Date()
private var isViewingToday: Bool { selectedDate.isSameDay(as: Date()) }
/// The viewed day is strictly before today (day-precision).
private var isViewingPastDay: Bool { selectedDate < Calendar.current.startOfDay(for: Date()) }
/// Schedules that belong on the viewed day's board: recurring ones always show
/// (the board displays their recurrence, not a due-filter yet); a one-off shows
/// only on its own day. A dateless `.once` (shouldn't happen) shows everywhere
/// rather than nowhere.
private var visibleSchedules: [Schedule] {
schedules.filter { schedule in
guard schedule.recurrenceEnum == .once, let date = schedule.date else { return true }
return date.isSameDay(as: selectedDate)
}
}
/// Non-empty goal groups in the user's chosen goal order.
private var goalGroups: [(kind: GoalKind, schedules: [Schedule])] {
GoalKind.orderedCases().compactMap { kind in
let matches = visibleSchedules.filter { $0.goalKind == kind }
return matches.isEmpty ? nil : (kind, matches)
}
}
/// Schedules with no goal, or a goal raw value this version doesn't know.
private var unassignedSchedules: [Schedule] {
visibleSchedules.filter { $0.goalKind == nil }
}
/// Motivational openers for the board title each reads as a phrase completed
/// by "Today" ("Seize Today"). One per day, rotating by day-of-era: stable all
/// day, fresh the next.
private static let greetings: [String] = [
"Seize",
"Own",
"Win",
"Conquer",
"Show Up",
"Stay the Course",
"Make It Count",
"Do the Work",
"Start Strong",
"No Excuses",
"Begin Again",
"Keep the Streak",
]
private var greetingLine: String {
let day = Calendar.current.ordinality(of: .day, in: .era, for: Date()) ?? 0
return Self.greetings[day % Self.greetings.count]
}
var body: some View {
NavigationStack {
List {
ForEach(goalGroups, id: \.kind) { group in
Section {
ForEach(group.schedules) { scheduleRow($0) }
} header: {
Label(group.kind.displayName, systemImage: group.kind.systemImage)
.foregroundStyle(Color.color(from: group.kind.colorName))
}
}
if !unassignedSchedules.isEmpty {
Section("Unassigned") {
ForEach(unassignedSchedules) { scheduleRow($0) }
}
}
}
.overlay {
if schedules.isEmpty {
ContentUnavailableView(
"No Schedules Yet",
systemImage: "calendar.badge.plus",
description: Text("Plan your week by adding a schedule for a routine.")
)
} else if visibleSchedules.isEmpty {
ContentUnavailableView(
"Nothing Planned",
systemImage: "calendar",
description: Text("Nothing is scheduled for this day. Tap + to plan something.")
)
}
}
.navigationTitle(isViewingToday ? "\(greetingLine) Today" : selectedDate.formatDate())
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
showingDayPicker = true
} label: {
Label("Go to Day", systemImage: "calendar")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showingAddSchedule = true
} label: {
Label("Add Schedule", systemImage: "plus")
}
}
}
// Row taps land here for both cases: a fresh start (entity arrives a beat
// after the file write) and an existing today-workout (resolves at once).
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
.safeAreaInset(edge: .bottom) {
SyncStatusBanner()
.padding(.horizontal)
.padding(.bottom, 8)
}
.sheet(isPresented: $showingDayPicker) {
DayPickerSheet(selectedDate: $selectedDate, workoutDays: workoutDays)
}
.sheet(isPresented: $showingAddSchedule) {
// Seed a would-be one-off with the day on screen, so navigating to a
// day and tapping + plans that day.
ScheduleAddEditView(defaultDate: selectedDate)
}
.sheet(item: $scheduleToEdit) { schedule in
ScheduleAddEditView(schedule: schedule)
}
}
}
// MARK: - Row
private func scheduleRow(_ schedule: Schedule) -> some View {
let routine = resolvedRoutine(for: schedule)
let today = todaysWorkout(for: schedule)
return Button {
openOrStart(schedule: schedule, routine: routine, today: today)
} label: {
HStack(spacing: 12) {
Image(systemName: routine?.systemImage ?? "figure.strengthtraining.traditional")
.font(.title3)
.foregroundStyle(iconColor(routine: routine, schedule: schedule))
.frame(width: 32)
VStack(alignment: .leading, spacing: 2) {
Text(routine?.name ?? schedule.routineName)
.foregroundStyle(.primary)
Text(schedule.recurrenceSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let today {
todayStatus(for: today)
} else if isViewingPastDay {
skippedTag
}
}
.contentShape(Rectangle())
}
.tint(.primary)
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
Task { await sync.delete(schedule: schedule) }
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
Button {
scheduleToEdit = schedule
} label: {
Label("Edit", systemImage: "pencil")
}
}
}
/// Today's status decoration: a completed run shows a green check + its finish
/// time; an unfinished one shows an orange "In Progress" tag.
@ViewBuilder
private func todayStatus(for workout: Workout) -> some View {
if workout.status == .completed {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text((workout.end ?? workout.start).formattedTime())
.font(.caption)
.foregroundStyle(.secondary)
}
} else {
Text("In Progress")
.font(.caption2.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.orange.opacity(0.15), in: Capsule())
}
}
/// Signpost on a past-day schedule row with no logged workout. Deliberately
/// undifferentiated for now a later pass will distinguish *abandoned* from an
/// explicit skip carrying an excuse ("sick day"); see UX-REDESIGN.md.
private var skippedTag: some View {
Text("Skipped")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(.secondary.opacity(0.15), in: Capsule())
}
// MARK: - Resolution helpers
/// The live routine this schedule points at, following the seed clone-on-edit
/// redirect; nil when the routine has been deleted.
private func resolvedRoutine(for schedule: Schedule) -> Routine? {
let liveID = sync.currentRoutineID(for: schedule.routineID)
return routines.first { $0.id == liveID }
}
/// The most recent workout started on the viewed day for this schedule's routine
/// (comparing through the clone redirect on both sides). `workouts` is
/// start-descending, so the first match is the newest.
private func todaysWorkout(for schedule: Schedule) -> Workout? {
let liveRoutineID = sync.currentRoutineID(for: schedule.routineID)
return workouts.first { workout in
guard let rid = workout.routineID, workout.start.isSameDay(as: selectedDate) else { return false }
return sync.currentRoutineID(for: rid) == liveRoutineID
}
}
/// Day-precision (year/month/day) components of every day with at least one
/// logged workout the day picker decorates these with a dot.
private var workoutDays: Set<DateComponents> {
Set(workouts.map { Calendar.current.dateComponents([.year, .month, .day], from: $0.start) })
}
/// Row icon color: the routine's own color, or the goal's color as a generic
/// fallback when the routine is gone.
private func iconColor(routine: Routine?, schedule: Schedule) -> Color {
if let routine { return Color.color(from: routine.color) }
return Color.color(from: schedule.goalKind?.colorName ?? "")
}
/// Tap: open the viewed day's workout if one exists, otherwise start a fresh one
/// from the resolved routine but only when the board is on today, since a start
/// always happens *now*. Does nothing on other days without a workout, or for an
/// orphaned schedule (no live routine).
private func openOrStart(schedule: Schedule, routine: Routine?, today: Workout?) {
if let today {
guard !today.isDeleted else { return }
pendingWorkoutID = today.id
} else if isViewingToday, let routine {
Task {
pendingWorkoutID = await WorkoutStarter.start(routine: routine, sync: sync)
}
}
}
}
@@ -40,7 +40,7 @@ struct ExerciseProgressView: View {
/// True when this run was auto-advanced into from the previous exercise (flow mode).
/// It suppresses the Ready page and begins the exercise immediately on appear the
/// whole point of a hands-free flowing split.
/// whole point of a hands-free flowing routine.
let enteredViaFlow: Bool
/// Invoked at the terminal rest's end in flow mode to hand off to the next exercise;
@@ -196,11 +196,11 @@ struct ExerciseProgressView: View {
/// appear, hands-free.
private var showsReady: Bool { !enteredViaFlow }
/// This split runs as a continuous flow finishing one exercise rests, then
/// This routine runs as a continuous flow finishing one exercise rests, then
/// auto-advances into the next rather than returning to the list per exercise.
private var autoAdvance: Bool { doc.autoAdvance == true }
/// Rest length for this run: the split's per-split override, else the global default.
/// Rest length for this run: the routine's per-routine override, else the global default.
/// Governs both between-set rests and (in flow mode) the between-exercise rest.
private var effectiveRest: Int { max(1, doc.restSeconds ?? restSeconds) }
@@ -186,7 +186,7 @@ struct ExerciseView: View {
/// Apply edited machine settings: update the log's plan-time snapshot (persisted
/// with the workout), then write the same settings back to the originating
/// split's exercise as the durable default same sequencing as the workout
/// routine's exercise as the durable default same sequencing as the workout
/// list's machine sheet.
private func applyMachineSettings(_ settings: [MachineSetting]) {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
@@ -195,10 +195,10 @@ struct ExerciseView: View {
doc.updatedAt = Date()
let snapshot = doc
let exerciseName = doc.logs[i].exerciseName
let splitID = doc.splitID
let routineID = doc.routineID
Task {
await sync.save(workout: snapshot)
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
}
}
@@ -163,13 +163,13 @@ struct PlanEditView: View {
let exerciseName = doc.logs[i].exerciseName
let workoutDoc = doc
// 2) Mirror the plan onto the matching exercise in the split template,
// 2) Mirror the plan onto the matching exercise in the routine template,
// following the seed clone-on-edit redirect so a prior fork doesn't
// silently break the mirror.
var splitDoc: SplitDocument?
if let splitID = doc.splitID,
let split = CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext) {
var sDoc = SplitDocument(from: split)
var routineDoc: RoutineDocument?
if let routineID = doc.routineID,
let routine = CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID), in: modelContext) {
var sDoc = RoutineDocument(from: routine)
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
sDoc.exercises[ei].sets = sets
sDoc.exercises[ei].reps = reps
@@ -177,14 +177,14 @@ struct PlanEditView: View {
sDoc.exercises[ei].durationSeconds = totalSeconds
sDoc.exercises[ei].loadType = selectedLoadType.rawValue
sDoc.updatedAt = Date()
splitDoc = sDoc
routineDoc = sDoc
}
}
Task {
await sync.save(workout: workoutDoc)
if let splitDoc {
await sync.save(split: splitDoc)
if let routineDoc {
await sync.save(routine: routineDoc)
}
}
}
+2 -2
View File
@@ -8,12 +8,12 @@
import SwiftUI
/// Thin coordinator that lets a single pushed run walk across exercises in a flow-mode
/// split (`autoAdvance`). It owns which log is on screen and swaps it when the running
/// routine (`autoAdvance`). It owns which log is on screen and swaps it when the running
/// `ExerciseProgressView` finishes an exercise; `.id(currentLogID)` rebuilds the child
/// fresh for each exercise, so the delicate per-exercise run/mirror machinery stays
/// exactly as it is this only automates the "open the next exercise" step.
///
/// For a non-flow split nothing ever calls `advance`, so this is transparent: it presents
/// For a non-flow routine nothing ever calls `advance`, so this is transparent: it presents
/// one exercise and behaves identically to hosting `ExerciseProgressView` directly.
struct RunFlowView: View {
@Environment(LiveRunState.self) private var liveRun
@@ -0,0 +1,101 @@
//
// WorkoutHistoryView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 6:52 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import SwiftUI
import SwiftData
/// The full workout history list. Formerly the "Log" tab's root (`WorkoutLogsView`);
/// now pushed from the Progress tab, which absorbed history in the four-tab redesign.
struct WorkoutHistoryView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@State private var itemToDelete: Workout?
var body: some View {
List {
ForEach(workouts) { workout in
NavigationLink {
WorkoutLogListView(workout: workout)
} label: {
CalendarListItem(
date: workout.start,
title: workout.routineName ?? Routine.unnamed,
subtitle: getSubtitle(for: workout),
subtitle2: workout.statusName
)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
itemToDelete = workout
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
}
.overlay {
if workouts.isEmpty {
ContentUnavailableView(
"No Workouts Yet",
systemImage: "list.bullet.clipboard",
description: Text("Start a new workout from one of your routines.")
)
}
}
.navigationTitle("History")
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
// On the log screen so a stalled save is visible where editing happens.
.safeAreaInset(edge: .bottom) {
SyncStatusBanner()
}
.confirmationDialog(
"Delete Workout?",
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible,
presenting: itemToDelete
) { workout in
Button("Delete", role: .destructive) {
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
// Only offered when the workout actually has a Health record. Capture
// the UUID before the delete prunes the cache entity.
if let healthUUID = workout.metricHealthKitWorkoutUUID {
Button("Delete + Remove from Apple Health", role: .destructive) {
services.workoutHealthDeleter.deleteFromHealth(uuidString: healthUUID)
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
} message: { workout in
if workout.metricSourceRaw == MetricSource.watch.rawValue {
Text("This workout was recorded by Apple Watch; if it can't be removed from Apple Health here, delete it in the Health app.")
}
}
}
private func getSubtitle(for workout: Workout) -> String {
if workout.status == .completed, let endDate = workout.end {
return workout.start.humanTimeInterval(to: endDate)
} else {
return workout.start.formattedDate()
}
}
}
@@ -55,12 +55,12 @@ struct WorkoutLogListView: View {
doc.logs.sorted { $0.order < $1.order }
}
/// The split this workout was started from (for adding more exercises),
/// The routine this workout was started from (for adding more exercises),
/// following the seed clone-on-edit redirect so a mid-workout fork still
/// resolves to the live clone.
private var split: Split? {
guard let splitID = doc.splitID else { return nil }
return CacheMapper.fetchSplit(id: sync.currentSplitID(for: splitID), in: modelContext)
private var routine: Routine? {
guard let routineID = doc.routineID else { return nil }
return CacheMapper.fetchRoutine(id: sync.currentRoutineID(for: routineID), in: modelContext)
}
var body: some View {
@@ -166,7 +166,7 @@ struct WorkoutLogListView: View {
// doc so it shows the latest local state immediately.
ExerciseView(workout: workout, logID: route.id, seedDoc: doc)
}
.navigationTitle(doc.splitName ?? Split.unnamed)
.navigationTitle(doc.routineName ?? Routine.unnamed)
// Absorb edits made in pushed children (ExerciseView/Plan/Notes) once the
// cache reflects them, so the list shows live status on return.
.onChange(of: workout.updatedAt) { _, _ in
@@ -185,12 +185,12 @@ struct WorkoutLogListView: View {
}
}
.sheet(isPresented: $showingAddSheet) {
SplitExercisePickerSheet(
split: split,
RoutineExercisePickerSheet(
routine: routine,
existingExerciseNames: Set(sortedLogs.map { $0.exerciseName })
) { selection in
switch selection {
case .fromSplit(let exercise): addExerciseFromSplit(exercise)
case .fromRoutine(let exercise): addExerciseFromRoutine(exercise)
case .fromLibrary(let name): addExerciseFromLibrary(name)
}
}
@@ -246,7 +246,7 @@ struct WorkoutLogListView: View {
/// (so the propped-phone mirror cover doesn't stack on top of it).
@ViewBuilder
private func progressView(logID: String) -> some View {
// RunFlowView hosts the run and, in a flow-mode split, auto-advances across
// RunFlowView hosts the run and, in a flow-mode routine, auto-advances across
// exercises. It owns the live-run mirror wiring (incoming frame filter,
// `navigatedRunID`, and the parameterized live-ended signal) since the on-screen
// log advances during a flowing run.
@@ -321,11 +321,11 @@ struct WorkoutLogListView: View {
save()
}
private func addExerciseFromSplit(_ exercise: Exercise) {
private func addExerciseFromRoutine(_ exercise: Exercise) {
appendExercise(ExerciseDocument(from: exercise))
}
/// Add an exercise that isn't part of this workout's split, picked from the full
/// Add an exercise that isn't part of this workout's routine, picked from the full
/// exercise library. Seeds a plan from, in priority order: the most recent logged
/// set of this exercise (any workout), the library's authored `**Defaults:**`, or
/// a plain 3×10 fallback when neither exists.
@@ -334,7 +334,7 @@ struct WorkoutLogListView: View {
}
/// Append a freshly-planned exercise to the working doc, persist, and push
/// straight into its progress flow. Shared tail of `addExerciseFromSplit` and
/// straight into its progress flow. Shared tail of `addExerciseFromRoutine` and
/// `addExerciseFromLibrary`.
private func appendExercise(_ plan: ExerciseDocument) {
let now = Date()
@@ -354,7 +354,7 @@ struct WorkoutLogListView: View {
addedLog = LogRoute(id: newLog.id)
}
/// Plan defaults for a library exercise with no split entry: copy the most recent
/// Plan defaults for a library exercise with no routine entry: copy the most recent
/// logged set of this exercise (sets/reps/weight/loadType/duration and machine
/// settings) if one exists anywhere; otherwise the library's authored defaults
/// (weight always starts at 0 there's no prior lift to copy); otherwise a plain
@@ -396,9 +396,9 @@ struct WorkoutLogListView: View {
/// Apply edited machine settings from the sheet: update the log's plan-time
/// snapshot (persisted with the workout), then write the same settings back to
/// the originating split's exercise as the durable default. Sequenced in one
/// task so the split's clone-on-edit repoint (which rewrites this workout's
/// `splitID`) sees the log edit already persisted rather than clobbering it.
/// the originating routine's exercise as the durable default. Sequenced in one
/// task so the routine's clone-on-edit repoint (which rewrites this workout's
/// `routineID`) sees the log edit already persisted rather than clobbering it.
private func applyMachineSettings(_ settings: [MachineSetting], toLogID id: String) {
guard let i = doc.logs.firstIndex(where: { $0.id == id }) else { return }
doc.logs[i].machineSettings = settings
@@ -408,10 +408,10 @@ struct WorkoutLogListView: View {
let workoutSnapshot = doc
let exerciseName = doc.logs[i].exerciseName
let splitID = doc.splitID
let routineID = doc.routineID
Task {
await sync.save(workout: workoutSnapshot)
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID)
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
}
}
@@ -579,12 +579,12 @@ private struct WorkoutLogRow: View {
}
}
// MARK: - Split Exercise Picker Sheet
// MARK: - Routine Exercise Picker Sheet
struct SplitExercisePickerSheet: View {
struct RoutineExercisePickerSheet: View {
@Environment(\.dismiss) private var dismiss
let split: Split?
let routine: Routine?
let existingExerciseNames: Set<String>
let onSelected: (Selection) -> Void
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
@@ -593,31 +593,31 @@ struct SplitExercisePickerSheet: View {
/// library section's name list is stable for the sheet's lifetime.
@State private var libraryInfoByName: [String: ExerciseInfo] = [:]
/// A pick from either source: the split path hands back the live `Exercise`
/// A pick from either source: the routine path hands back the live `Exercise`
/// entity (as today), the library path just the exercise name.
enum Selection {
case fromSplit(Exercise)
case fromRoutine(Exercise)
case fromLibrary(String)
}
/// Section 1 this workout's split, minus exercises already added.
private var splitExercises: [Exercise] {
guard let split else { return [] }
return split.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
/// Section 1 this workout's routine, minus exercises already added.
private var routineExercises: [Exercise] {
guard let routine else { return [] }
return routine.exercisesArray.filter { !existingExerciseNames.contains($0.name) }
}
/// Section 2 every library exercise not already in the workout and not
/// already offered in section 1.
private var libraryExerciseNames: [String] {
let shown = Set(splitExercises.map(\.name))
let shown = Set(routineExercises.map(\.name))
return ExerciseMotionLibrary.exerciseNames.filter {
!existingExerciseNames.contains($0) && !shown.contains($0)
}
}
private var filteredSplitExercises: [Exercise] {
guard !searchText.isEmpty else { return splitExercises }
return splitExercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
private var filteredRoutineExercises: [Exercise] {
guard !searchText.isEmpty else { return routineExercises }
return routineExercises.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
private var filteredLibraryNames: [String] {
@@ -628,7 +628,7 @@ struct SplitExercisePickerSheet: View {
/// Whether there's anything left to add at all independent of the current
/// search, so a no-results search doesn't get confused with a truly empty sheet.
private var hasAnyExercises: Bool {
!splitExercises.isEmpty || !libraryExerciseNames.isEmpty
!routineExercises.isEmpty || !libraryExerciseNames.isEmpty
}
/// "3 × 12" / "3 × 30 s" the library's suggested plan for a row's caption.
@@ -648,15 +648,15 @@ struct SplitExercisePickerSheet: View {
systemImage: "checkmark.circle",
description: Text("Every exercise is already part of this workout.")
)
} else if filteredSplitExercises.isEmpty && filteredLibraryNames.isEmpty {
} else if filteredRoutineExercises.isEmpty && filteredLibraryNames.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List {
if !filteredSplitExercises.isEmpty, let split {
Section("From \(split.name)") {
ForEach(filteredSplitExercises) { exercise in
if !filteredRoutineExercises.isEmpty, let routine {
Section("From \(routine.name)") {
ForEach(filteredRoutineExercises) { exercise in
Button {
onSelected(.fromSplit(exercise))
onSelected(.fromRoutine(exercise))
dismiss()
} label: {
HStack {
@@ -730,7 +730,7 @@ struct SplitExercisePickerSheet: View {
/// machine-based log (non-nil `machineSettings`), so there's no machine toggle
/// just the ordered rows (reusing `MachineSettingsEditor`) plus Save/Cancel. Save
/// hands the edited array back to the caller, which persists it to the log and
/// writes it back to the split's exercise as the new default. Shared with
/// writes it back to the routine's exercise as the new default. Shared with
/// `ExerciseView`'s Machine Settings section.
struct MachineSettingsSheet: View {
@Environment(\.dismiss) private var dismiss
@@ -750,7 +750,7 @@ struct MachineSettingsSheet: View {
Form {
Section(
header: Text("Settings"),
footer: Text("Saved to this workout and set as the default for \(exerciseName) in its split.")
footer: Text("Saved to this workout and set as the default for \(exerciseName) in its routine.")
) {
MachineSettingsEditor(settings: $settings)
}
@@ -1,381 +0,0 @@
//
// WorkoutLogsView.swift
// Workouts
//
// Created by rzen on 7/13/25 at 6:52 PM.
//
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
struct WorkoutLogsView: View {
@Environment(SyncEngine.self) private var sync
@Environment(AppServices.self) private var services
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
@State private var showingSplitPicker = false
@State private var showingSettings = false
@State private var itemToDelete: Workout?
/// ID of the just-started workout; drives the push into its log screen once the
/// cache observer delivers the entity a beat after the file write (see
/// `navigatesToStartedWorkout`).
@State private var pendingWorkoutID: String?
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
var body: some View {
NavigationStack {
List {
ForEach(workouts) { workout in
NavigationLink {
WorkoutLogListView(workout: workout)
} label: {
CalendarListItem(
date: workout.start,
title: workout.splitName ?? Split.unnamed,
subtitle: getSubtitle(for: workout),
subtitle2: workout.statusName
)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button {
itemToDelete = workout
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
}
.overlay {
if workouts.isEmpty {
ContentUnavailableView(
"No Workouts Yet",
systemImage: "list.bullet.clipboard",
description: Text("Start a new workout from one of your splits.")
)
}
}
.navigationTitle("Workout Logs")
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
// On the root screen so a stalled save is visible where editing happens.
.safeAreaInset(edge: .bottom) {
SyncStatusBanner()
}
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
showingSettings = true
} label: {
Image(systemName: "gearshape.2")
}
.accessibilityLabel("Settings")
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Start New") {
showingSplitPicker.toggle()
}
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
}
.sheet(isPresented: $showingSplitPicker) {
SplitPickerSheet { pendingWorkoutID = $0 }
}
// Once "Start New" saves a workout, drop straight into its log screen
// the same landing the split's exercise-list start path uses.
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
.confirmationDialog(
"Delete Workout?",
isPresented: Binding(
get: { itemToDelete != nil },
set: { if !$0 { itemToDelete = nil } }
),
titleVisibility: .visible,
presenting: itemToDelete
) { workout in
Button("Delete", role: .destructive) {
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
// Only offered when the workout actually has a Health record. Capture
// the UUID before the delete prunes the cache entity.
if let healthUUID = workout.metricHealthKitWorkoutUUID {
Button("Delete + Remove from Apple Health", role: .destructive) {
services.workoutHealthDeleter.deleteFromHealth(uuidString: healthUUID)
Task { await sync.delete(workout: workout) }
itemToDelete = nil
}
}
Button("Cancel", role: .cancel) {
itemToDelete = nil
}
} message: { workout in
if workout.metricSourceRaw == MetricSource.watch.rawValue {
Text("This workout was recorded by Apple Watch; if it can't be removed from Apple Health here, delete it in the Health app.")
}
}
}
}
private func getSubtitle(for workout: Workout) -> String {
if workout.status == .completed, let endDate = workout.end {
return workout.start.humanTimeInterval(to: endDate)
} else {
return workout.start.formattedDate()
}
}
}
// MARK: - Split Picker Sheet
struct SplitPickerSheet: 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(\Split.order), SortDescriptor(\Split.name)])
private var splits: [Split]
@Query(sort: \Workout.start, order: .reverse)
private var workouts: [Workout]
/// Set when the user picks a split while other workouts are still going drives the
/// "end the current one(s) or run in parallel?" prompt.
@State private var splitAwaitingConfirmation: Split?
private var activeWorkouts: [Workout] {
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
}
/// The splits 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 `splitID` 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 splits that
/// still exist.
private var recentSplits: [(split: Split, lastStart: Date)] {
var seen = Set<String>()
var recents: [(split: Split, lastStart: Date)] = []
for workout in workouts {
guard let splitID = workout.splitID else { continue }
let liveID = sync.currentSplitID(for: splitID)
guard !seen.contains(liveID) else { continue }
seen.insert(liveID)
if let split = splits.first(where: { $0.id == liveID }) {
recents.append((split, workout.start))
if recents.count == 3 { break }
}
}
return recents
}
/// One selectable split row, shared by the Recent and All Splits sections.
/// Recent rows pass `lastTrained` to show how long ago the split was run.
private func splitRow(_ split: Split, lastTrained: Date? = nil) -> some View {
Button {
confirmAndStart(with: split)
} label: {
HStack {
Image(systemName: split.systemImage)
.foregroundColor(Color.color(from: split.color))
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(split.name)
if let lastTrained {
Text(lastTrained.daysAgoLabel())
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Text("\(split.exercisesArray.count) exercises")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
var body: some View {
NavigationStack {
List {
if !recentSplits.isEmpty {
Section("Recent") {
ForEach(recentSplits, id: \.split.id) { recent in
splitRow(recent.split, lastTrained: recent.lastStart)
}
}
}
Section(recentSplits.isEmpty ? "" : "All Splits") {
ForEach(splits) { split in
splitRow(split)
}
}
}
.navigationTitle("Select a Split")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Cancel") {
dismiss()
}
}
}
.confirmationDialog(
activePromptTitle,
isPresented: Binding(
get: { splitAwaitingConfirmation != nil },
set: { if !$0 { splitAwaitingConfirmation = nil } }
),
titleVisibility: .visible,
presenting: splitAwaitingConfirmation
) { split in
Button("End Current & Start New") { endActiveThenStart(with: split) }
Button("Start in Parallel") { start(with: split) }
Button("Cancel", role: .cancel) { splitAwaitingConfirmation = 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 split: Split) {
if activeWorkouts.isEmpty {
start(with: split)
} else {
splitAwaitingConfirmation = split
}
}
/// End every in-flight workout (keeping its progress), then start the picked split.
private func endActiveThenStart(with split: Split) {
let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) }
splitAwaitingConfirmation = nil
Task {
for var doc in toEnd {
doc.endKeepingProgress()
await sync.save(workout: doc)
}
}
start(with: split)
}
private func start(with split: Split) {
let startDate = Date()
let logs = split.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(),
splitID: split.id,
splitName: split.name,
start: startDate,
end: nil,
status: WorkoutStatus.notStarted.rawValue,
createdAt: startDate,
updatedAt: startDate,
logs: logs,
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance
)
// 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 {
await sync.save(workout: doc)
onStarted(doc.id)
}
// Bring the Apple Watch up into the session so the user can run it from the wrist,
// tagged with the split's activity type so the saved Health workout is categorized.
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
dismiss()
}
}
// MARK: - Started-Workout Navigation
/// Drives a programmatic push into a freshly-started workout's log screen. Both start
/// paths the split picker sheet and a split's exercise list mint a `WorkoutDocument`,
/// write it, then hand its id here; the workout row only materializes once the
/// fileobservercache 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
@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)
}
}
/// Poll for the entity after we write the document (the fileobservercache 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))
}
}
@@ -33,7 +33,7 @@ struct WorkoutSummaryView: View {
.font(.system(size: 48))
.foregroundStyle(.green)
.padding(.top, 8)
Text(workout.splitName ?? Split.unnamed)
Text(workout.routineName ?? Routine.unnamed)
.font(.title2.bold())
WorkoutMetricsView(workout: workout)