Anchor auto-advanced phases at the boundary, not at tick time
The rest/timed-work countdown deadline is shared by both devices, but the page flip crossing it is a local ticker event — and stamping the *next* phase's anchor at Date() when that event finally ran baked a sleeping watch's lateness (throttled wrist-down ticker) into its next count-up, leaving the two devices permanently offset with nothing on the wire to correct. Auto-advances now chain the anchor instead: the finished phase's computed end (passed out of CountdownPhaseView) becomes the next page's PageAnchor, with the next window derived from it via liveSnapshot(for:at:). A device arbitrarily late to a boundary shows exactly what the on-time device shows, and a stack of missed boundaries fast-forwards itself — each chained page lands already-elapsed and advances on its own next tick, skipping the start/stop haptics for boundaries that passed while asleep (only a just-crossed boundary buzzes). The remoteAnchor* fields are generalized into one PageAnchor (remote frames and chained auto-advances are the same concept: a page whose timer counts from a known instant); the phone's Live Activity emit honors it unchanged.
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
**July 2026**
|
**July 2026**
|
||||||
|
|
||||||
|
Watch and iPhone set timers no longer drift apart when the watch sleeps through the end of a rest.
|
||||||
|
|
||||||
Swiping through sets on the iPhone now reliably moves the watch along too, instead of the two drifting apart mid-workout.
|
Swiping through sets on the iPhone now reliably moves the watch along too, instead of the two drifting apart mid-workout.
|
||||||
|
|
||||||
On Apple Watch, the workout screen now gives its whole display to the set and rest timer, without the form-guide figure shown on iPhone.
|
On Apple Watch, the workout screen now gives its whole display to the set and rest timer, without the form-guide figure shown on iPhone.
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
# Phone ↔ Watch Communication
|
||||||
|
|
||||||
|
How the iPhone app and the Watch app talk to each other. The governing rule: **the
|
||||||
|
iPhone is the sole writer of iCloud Drive**. The watch never touches iCloud — it keeps
|
||||||
|
a local SwiftData cache fed only by phone pushes, edits optimistically, and round-trips
|
||||||
|
every durable change through the phone.
|
||||||
|
|
||||||
|
> For a per-scenario walkthrough (phone/watch starts a split, drives an exercise, ends a
|
||||||
|
> run, edit locks, cold launch, …) as sequence + state diagrams — including where the
|
||||||
|
> watch's HealthKit session leaks — see [`WATCH-SYNC-SCENARIOS.md`](WATCH-SYNC-SCENARIOS.md).
|
||||||
|
|
||||||
|
Source files:
|
||||||
|
|
||||||
|
- Wire format: `Shared/Connectivity/WCPayload.swift`
|
||||||
|
- Phone side: `Workouts/Connectivity/PhoneConnectivityBridge.swift`
|
||||||
|
- Watch side: `Workouts Watch App/Connectivity/WatchConnectivityBridge.swift`
|
||||||
|
- Phone ingest: `SyncEngine.ingestFromWatch` (`Workouts/Sync/SyncEngine.swift`)
|
||||||
|
- Watch app launch: `Workouts/HealthKit/WorkoutLauncher.swift` + `Workouts Watch App/WorkoutSessionManager.swift`
|
||||||
|
|
||||||
|
## Channels at a glance
|
||||||
|
|
||||||
|
| Channel | Direction | WatchConnectivity API | Semantics | Payload |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| State push | Phone → Watch | `updateApplicationContext` | Latest-wins, delivered even if watch is asleep | All splits + active/recent workouts (JSON `Data` blobs), settings, edit locks |
|
||||||
|
| Workout update | Watch → Phone | `sendMessage`, falling back to `transferUserInfo` | Immediate when reachable; queued-guaranteed otherwise (unordered mix) | One `WorkoutDocument` |
|
||||||
|
| Sync request | Watch → Phone | `sendMessage` | Reachable-only, best-effort | Type marker only |
|
||||||
|
| Live-run mirror | Both ways | `sendMessage` only | Reachable-only + a depth-1 staged re-send on reconnect | `LiveProgress` frame / `liveEnded` marker |
|
||||||
|
| Watch app launch | Phone → Watch | — (HealthKit, not WC) | Best-effort | `HKWorkoutConfiguration` via `startWatchApp(toHandle:)` |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph iPhone
|
||||||
|
Views[SwiftUI views] --> SE[SyncEngine]
|
||||||
|
SE -->|writes files| ICD[(iCloud Drive)]
|
||||||
|
ICD -->|NSMetadataQuery deltas| SE
|
||||||
|
SE -->|upserts| PC[(SwiftData cache)]
|
||||||
|
SE -->|onCacheChanged| PB[PhoneConnectivityBridge]
|
||||||
|
PB --- LRS[LiveRunState]
|
||||||
|
WL[WorkoutLauncher]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Watch
|
||||||
|
WB[WatchConnectivityBridge]
|
||||||
|
WB -->|upsert + prune| WC[(SwiftData cache)]
|
||||||
|
WC -->|"@Query"| WViews[Watch views]
|
||||||
|
WViews -->|"update(workout:)"| WB
|
||||||
|
WSM[WorkoutSessionManager]
|
||||||
|
end
|
||||||
|
|
||||||
|
PB -->|"application context: splits + workouts + settings + edit locks"| WB
|
||||||
|
WB -->|"workoutUpdate / requestSync"| PB
|
||||||
|
PB <-->|"live-run frames (ephemeral)"| WB
|
||||||
|
WL -->|"HealthKit startWatchApp"| WSM
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Phone → Watch: the state push
|
||||||
|
|
||||||
|
`PhoneConnectivityBridge.pushAll()` serializes the phone's cache into one
|
||||||
|
`updateApplicationContext` dictionary. Application context is **latest-state-wins**:
|
||||||
|
watchOS keeps only the newest dictionary and delivers it when the watch app runs, so
|
||||||
|
every push must be complete — a push that omitted a key would read as that state being
|
||||||
|
cleared.
|
||||||
|
|
||||||
|
**What's in it:**
|
||||||
|
|
||||||
|
- **Splits** — all of them, as `[SplitDocument]` JSON.
|
||||||
|
- **Workouts** — only what the watch can act on: every active run (in-progress /
|
||||||
|
not-started, uncapped) plus up to 25 completed ones from the last ~24 h, as
|
||||||
|
`[WorkoutDocument]` JSON.
|
||||||
|
- **Settings** — `restSeconds`, `doneCountdownSeconds`, `weightUnit` (from the phone's
|
||||||
|
`UserDefaults`; the watch writes them into its own).
|
||||||
|
- **Edit locks** — `editingWorkoutID` / `editingSplitID`: while the phone has a workout
|
||||||
|
or split open in an editor, the watch parks any matching run and blocks re-entry, so
|
||||||
|
only one device drives a run at a time. Absent keys mean "not editing".
|
||||||
|
|
||||||
|
**When it fires:** on every `SyncEngine.onCacheChanged` (local edits *and* changes
|
||||||
|
arriving from iCloud), on session activation, on reachability restored, on a watch
|
||||||
|
`requestSync`, and immediately on any edit-lock change.
|
||||||
|
|
||||||
|
**How the watch applies it:** `WatchCacheApplier` upserts every document sent, then
|
||||||
|
**prunes** anything absent — the pushed sets are authoritative, which is how a delete
|
||||||
|
on the phone propagates (the deleted workout simply stops being sent). A payload that
|
||||||
|
fails to decode (phone and watch running different document schemas) is skipped
|
||||||
|
entirely — no upsert, no prune — so a bogus empty set can never wipe real rows.
|
||||||
|
|
||||||
|
## 2. Watch → Phone: the workout round trip
|
||||||
|
|
||||||
|
The watch edits its local cache **optimistically** (so its UI is instant), then
|
||||||
|
forwards the whole updated `WorkoutDocument` to the phone. Transport is
|
||||||
|
`sendMessage` when the phone is reachable, with a `transferUserInfo` fallback
|
||||||
|
(guaranteed, queued, survives the watch app dying) when it isn't or the send fails.
|
||||||
|
The two are **unordered** relative to each other, which is why the phone gates intake
|
||||||
|
by `updatedAt`.
|
||||||
|
|
||||||
|
`SyncEngine.ingestFromWatch` on the phone:
|
||||||
|
|
||||||
|
1. **Tombstone veto** — a workout deleted on the phone is never resurrected by a stale
|
||||||
|
watch copy; the phone just re-pushes authoritative state so the watch drops it.
|
||||||
|
2. **Pending-delete veto** — same veto for a queued delete whose stub hasn't landed yet.
|
||||||
|
3. **`updatedAt` intake gate** — strictly newer than the cache is accepted; strictly
|
||||||
|
older means the watch is behind (re-push state to correct it); equal is a
|
||||||
|
duplicate/echo (ignored). Note: this arbitrates timestamps, not content — see the
|
||||||
|
known limit documented at `ingestFromWatch` (durable fix would be per-log merge).
|
||||||
|
4. An accepted document is written to iCloud Drive and upserted into the cache, which
|
||||||
|
fires `onCacheChanged` → `pushAll()` — so the watch always gets an authoritative echo.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant WV as Watch view
|
||||||
|
participant WB as WatchConnectivityBridge
|
||||||
|
participant PB as PhoneConnectivityBridge
|
||||||
|
participant SE as SyncEngine (phone)
|
||||||
|
participant IC as iCloud Drive
|
||||||
|
|
||||||
|
WV->>WB: update(workout doc)
|
||||||
|
WB->>WB: optimistic upsert into watch cache
|
||||||
|
alt phone reachable
|
||||||
|
WB->>PB: sendMessage(workoutUpdate)
|
||||||
|
else unreachable or send failed
|
||||||
|
WB->>PB: transferUserInfo(workoutUpdate) — queued
|
||||||
|
end
|
||||||
|
PB->>SE: ingestFromWatch(doc)
|
||||||
|
alt tombstoned or pending delete
|
||||||
|
SE-->>PB: veto — onCacheChanged only
|
||||||
|
else updatedAt strictly newer
|
||||||
|
SE->>IC: write Workouts/YYYY/MM/id.json
|
||||||
|
SE->>SE: upsert phone cache
|
||||||
|
SE-->>PB: onCacheChanged
|
||||||
|
end
|
||||||
|
PB->>WB: updateApplicationContext (authoritative echo)
|
||||||
|
WB->>WB: upsert + prune watch cache
|
||||||
|
```
|
||||||
|
|
||||||
|
A cold-starting watch sends `requestSync` (and re-applies the last received context
|
||||||
|
eagerly); the phone answers with a fresh `pushAll()`.
|
||||||
|
|
||||||
|
## 3. Live-run mirror (ephemeral, both directions)
|
||||||
|
|
||||||
|
While a run's exercise flow is open on one device, that device broadcasts
|
||||||
|
`LiveProgress` frames — workout/log IDs, exercise name, phase, set index/count, and a
|
||||||
|
**wall-clock phase anchor** — so the other device can follow along (the phone's
|
||||||
|
follower cover, or the watch's, unless that run is already open there or the user
|
||||||
|
dismissed it). `liveEnded` tells the peer to drop the follower.
|
||||||
|
|
||||||
|
This channel is deliberately *not* persistence:
|
||||||
|
|
||||||
|
- Frames go over `sendMessage` only (reachable-only); they are never written anywhere.
|
||||||
|
- Each side stages **one** pending frame (latest-wins) and re-sends it when
|
||||||
|
reachability or activation returns — a newer frame or the terminal `liveEnded`
|
||||||
|
replaces whatever is staged, so a re-send is never stale. Because frames carry an
|
||||||
|
absolute wall-clock anchor, a late arrival self-corrects its timers.
|
||||||
|
- A send that *fails* while the peer is nominally reachable (no reachability edge to
|
||||||
|
re-flush on) is retried with a short backoff, a handful of times per staged message.
|
||||||
|
- Receiving a frame that outranks the staged outbound one for the same run **drops the
|
||||||
|
staged frame** — the peer has moved past it, and a reconnect re-send would yank the
|
||||||
|
run backwards. Symmetrically, a delivery the staged frame outranks (late or
|
||||||
|
version-collided) is ignored.
|
||||||
|
- Both sides stamp frames from a shared monotonic `version` (each bumps its counter
|
||||||
|
past anything it receives), so either side can drop an out-of-order delivery. The
|
||||||
|
sequence isn't collision-free — after a lost frame both devices can mint the same
|
||||||
|
version — so every staleness comparison tie-breaks on the frame's wall-clock anchor
|
||||||
|
(`LiveProgress.isNewer`): the later human action wins.
|
||||||
|
- **Durable repair**: if a frame is lost outright (retries spent, no reconnect), the
|
||||||
|
transition's durable write still lands via its own channel. The run screens compare
|
||||||
|
the absorbed doc's `currentStateIndex` against everything they've recorded or
|
||||||
|
followed, and jump *forward* to the first unfinished set's work page — so a lost
|
||||||
|
frame degrades to a briefly-stale page, never a stuck one.
|
||||||
|
- **Auto-advances are never sent** — both devices cross countdown boundaries
|
||||||
|
independently off the shared deadline. Each crossing anchors the *next* phase at the
|
||||||
|
finished phase's computed end, not at tick time, so a device whose ticker slept
|
||||||
|
through the boundary (wrist-down throttling) shows the same next-phase timer as one
|
||||||
|
that crossed on time — and a stack of missed boundaries fast-forwards itself, one
|
||||||
|
tick per phase, silently (catch-up hops skip the haptics).
|
||||||
|
- Date anchors ride as native plist values, not JSON — `DocumentCoder` is ISO-8601,
|
||||||
|
which would round off the sub-second precision the timers need.
|
||||||
|
|
||||||
|
## 4. Launching the Watch app (HealthKit, not WatchConnectivity)
|
||||||
|
|
||||||
|
An iPhone app cannot foreground its Watch app via WatchConnectivity. When a workout
|
||||||
|
starts on the phone, `WorkoutLauncher` uses the one sanctioned path — HealthKit's
|
||||||
|
`startWatchApp(toHandle:)` with an `HKWorkoutConfiguration` — which wakes the Watch
|
||||||
|
app; there, `WorkoutSessionManager` starts a matching `HKWorkoutSession` so the watch
|
||||||
|
stays foregrounded for the duration of the run. Best-effort: it silently tolerates no
|
||||||
|
paired watch, and the session is runtime-only — it plays no part in data sync.
|
||||||
@@ -71,13 +71,19 @@ struct ExerciseProgressView: View {
|
|||||||
/// durable echo of our own progress (or of a frame we followed) never triggers a jump.
|
/// durable echo of our own progress (or of a frame we followed) never triggers a jump.
|
||||||
@State private var knownStateIndex: Int
|
@State private var knownStateIndex: Int
|
||||||
|
|
||||||
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
|
/// A page reached with a *known* wall-clock anchor counts its timer from that anchor
|
||||||
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency
|
/// instead of local `now`. Two sources: a remote frame (the sender's clock, so delivery
|
||||||
/// can't desync the mirror, the way the old read-only mirror counted off the frame's
|
/// latency can't desync the mirror) and a chained auto-advance (the finished phase's
|
||||||
/// anchors. Scoped to `remoteAnchorPage`; any local transition to another page self-anchors.
|
/// computed end, so a ticker that slept through the boundary — wrist-down throttling —
|
||||||
@State private var remoteAnchorPage: Int?
|
/// can't bake its lateness into the next phase's timer). Scoped to one page; a human
|
||||||
@State private var remoteAnchorStart: Date?
|
/// transition to another page self-anchors.
|
||||||
@State private var remoteAnchorEnd: Date?
|
@State private var pageAnchor: PageAnchor?
|
||||||
|
|
||||||
|
private struct PageAnchor {
|
||||||
|
let page: Int
|
||||||
|
let start: Date
|
||||||
|
let end: Date?
|
||||||
|
}
|
||||||
|
|
||||||
private enum PageChangeCause { case auto, remote }
|
private enum PageChangeCause { case auto, remote }
|
||||||
|
|
||||||
@@ -253,13 +259,13 @@ struct ExerciseProgressView: View {
|
|||||||
case .auto:
|
case .auto:
|
||||||
// Rest / timed-work auto-advance: record forward progress, but don't
|
// Rest / timed-work auto-advance: record forward progress, but don't
|
||||||
// broadcast — the mirror reaches this point on its own synchronized timer.
|
// broadcast — the mirror reaches this point on its own synchronized timer.
|
||||||
clearRemoteAnchor()
|
// The chained anchor for the new page was set by `advance`, not cleared.
|
||||||
recordProgress(for: newPage)
|
recordProgress(for: newPage)
|
||||||
case .none:
|
case .none:
|
||||||
// A human swipe. Swiping back from the first set to Ready wipes the run
|
// A human swipe. Swiping back from the first set to Ready wipes the run
|
||||||
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||||
// other page records forward progress. Human transitions are broadcast.
|
// other page records forward progress. Human transitions are broadcast.
|
||||||
clearRemoteAnchor()
|
clearAnchor()
|
||||||
if showsReady && newPage == 0 && oldPage == base {
|
if showsReady && newPage == 0 && oldPage == base {
|
||||||
resetExercise()
|
resetExercise()
|
||||||
} else {
|
} else {
|
||||||
@@ -333,9 +339,7 @@ struct ExerciseProgressView: View {
|
|||||||
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
|
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
|
||||||
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
||||||
// displayed countdown matches regardless of how long the frame took to arrive.
|
// displayed countdown matches regardless of how long the frame took to arrive.
|
||||||
remoteAnchorPage = target
|
pageAnchor = PageAnchor(page: target, start: frame.phaseStart, end: frame.phaseEnd)
|
||||||
remoteAnchorStart = frame.phaseStart
|
|
||||||
remoteAnchorEnd = frame.phaseEnd
|
|
||||||
guard target != currentPage else { return }
|
guard target != currentPage else { return }
|
||||||
pageChangeCause = .remote
|
pageChangeCause = .remote
|
||||||
withAnimation { currentPage = target }
|
withAnimation { currentPage = target }
|
||||||
@@ -357,17 +361,15 @@ struct ExerciseProgressView: View {
|
|||||||
if log.sets > setCount { setCount = log.sets }
|
if log.sets > setCount { setCount = log.sets }
|
||||||
let target = base + min(max(0, completed), setCount - 1) * 2
|
let target = base + min(max(0, completed), setCount - 1) * 2
|
||||||
guard target > currentPage else { return }
|
guard target > currentPage else { return }
|
||||||
clearRemoteAnchor()
|
clearAnchor()
|
||||||
pageChangeCause = .remote
|
pageChangeCause = .remote
|
||||||
withAnimation { currentPage = target }
|
withAnimation { currentPage = target }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
|
/// Drop the page anchor when a human transition moves us off the anchored page, so the
|
||||||
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
/// next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
||||||
private func clearRemoteAnchor() {
|
private func clearAnchor() {
|
||||||
remoteAnchorPage = nil
|
pageAnchor = nil
|
||||||
remoteAnchorStart = nil
|
|
||||||
remoteAnchorEnd = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
|
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
|
||||||
@@ -384,9 +386,8 @@ struct ExerciseProgressView: View {
|
|||||||
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
|
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
|
||||||
/// wall-clock anchors the mirror counts off. Count-down phases (rest, timed work, finish)
|
/// wall-clock anchors the mirror counts off. Count-down phases (rest, timed work, finish)
|
||||||
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
|
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
|
||||||
private func liveSnapshot(for page: Int) -> LiveProgress? {
|
private func liveSnapshot(for page: Int, at now: Date = Date()) -> LiveProgress? {
|
||||||
guard let log else { return nil }
|
guard let log else { return nil }
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
|
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
|
||||||
LiveProgress(
|
LiveProgress(
|
||||||
@@ -433,10 +434,10 @@ struct ExerciseProgressView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func page(for index: Int) -> some View {
|
private func page(for index: Int) -> some View {
|
||||||
let isActive = index == currentPage
|
let isActive = index == currentPage
|
||||||
// Only the remote-driven page carries the sender's anchors; every other page (reached
|
// Only the anchored page (remote frame or chained auto-advance) carries anchors;
|
||||||
// locally by swipe or auto-advance) counts off its own `now`.
|
// every other page (reached by a human transition) counts off its own `now`.
|
||||||
let anchorStart = index == remoteAnchorPage ? remoteAnchorStart : nil
|
let anchorStart = pageAnchor?.page == index ? pageAnchor?.start : nil
|
||||||
let anchorEnd = index == remoteAnchorPage ? remoteAnchorEnd : nil
|
let anchorEnd = pageAnchor?.page == index ? pageAnchor?.end : nil
|
||||||
if showsReady && index == 0 {
|
if showsReady && index == 0 {
|
||||||
ReadyPhaseView(summary: readySummary, onStart: start)
|
ReadyPhaseView(summary: readySummary, onStart: start)
|
||||||
} else {
|
} else {
|
||||||
@@ -461,8 +462,8 @@ struct ExerciseProgressView: View {
|
|||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
anchorStart: anchorStart,
|
anchorStart: anchorStart,
|
||||||
anchorEnd: anchorEnd
|
anchorEnd: anchorEnd
|
||||||
) {
|
) { end in
|
||||||
withAnimation { advance(from: index) }
|
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Rep-based work set — count up; the user swipes left when done.
|
// Rep-based work set — count up; the user swipes left when done.
|
||||||
@@ -483,8 +484,8 @@ struct ExerciseProgressView: View {
|
|||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
anchorStart: anchorStart,
|
anchorStart: anchorStart,
|
||||||
anchorEnd: anchorEnd
|
anchorEnd: anchorEnd
|
||||||
) {
|
) { end in
|
||||||
withAnimation { advance(from: index) }
|
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,11 +499,20 @@ struct ExerciseProgressView: View {
|
|||||||
withAnimation { currentPage = base }
|
withAnimation { currentPage = base }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Programmatically move one page right (used by the rest auto-advance), guarding
|
/// Programmatically move one page right when a countdown phase ends, guarding against
|
||||||
/// against overrun if the user swiped away in the meantime. Tagged `.auto` so the page
|
/// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
|
||||||
/// observer records progress but doesn't broadcast it (the mirror auto-advances too).
|
/// records progress but doesn't broadcast it (the mirror auto-advances too).
|
||||||
private func advance(from index: Int) {
|
///
|
||||||
|
/// `phaseEndedAt` is the finished phase's *computed* end — the next phase began then,
|
||||||
|
/// not when this tick finally got runtime. Anchoring the next page there means a device
|
||||||
|
/// that slept through the boundary (throttled wrist-down ticker) shows the same timer
|
||||||
|
/// as one that crossed it on time — and a stack of missed boundaries fast-forwards
|
||||||
|
/// itself, since each chained countdown lands already-elapsed and advances on its own
|
||||||
|
/// next tick.
|
||||||
|
private func advance(from index: Int, phaseEndedAt end: Date) {
|
||||||
guard currentPage == index, index + 1 < totalPages else { return }
|
guard currentPage == index, index + 1 < totalPages else { return }
|
||||||
|
pageAnchor = PageAnchor(page: index + 1, start: end,
|
||||||
|
end: liveSnapshot(for: index + 1, at: end)?.phaseEnd)
|
||||||
pageChangeCause = .auto
|
pageChangeCause = .auto
|
||||||
currentPage = index + 1
|
currentPage = index + 1
|
||||||
}
|
}
|
||||||
@@ -892,8 +902,10 @@ private struct CountdownPhaseView: View {
|
|||||||
/// so the remaining time — and the auto-advance at zero — line up across both devices.
|
/// so the remaining time — and the auto-advance at zero — line up across both devices.
|
||||||
var anchorStart: Date? = nil
|
var anchorStart: Date? = nil
|
||||||
var anchorEnd: Date? = nil
|
var anchorEnd: Date? = nil
|
||||||
/// Invoked once the countdown reaches zero (auto-advance to the next page).
|
/// Invoked once the countdown reaches zero (auto-advance to the next page), passing the
|
||||||
let onFinished: () -> Void
|
/// phase's *computed* end so the next phase can anchor at the boundary itself — not at
|
||||||
|
/// whenever this tick got runtime (a device asleep at the boundary ticks late).
|
||||||
|
let onFinished: (Date) -> Void
|
||||||
|
|
||||||
@State private var startDate = Date()
|
@State private var startDate = Date()
|
||||||
@State private var endDate = Date()
|
@State private var endDate = Date()
|
||||||
@@ -919,7 +931,9 @@ private struct CountdownPhaseView: View {
|
|||||||
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||||
lastPingSecond = Int.max
|
lastPingSecond = Int.max
|
||||||
didFinish = false
|
didFinish = false
|
||||||
if haptic { WorkoutHaptic.start.play() }
|
// No buzz for a chained catch-up page whose whole window already elapsed while
|
||||||
|
// the device slept — it advances again on its next tick.
|
||||||
|
if haptic, endDate > Date() { WorkoutHaptic.start.play() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tick() {
|
private func tick() {
|
||||||
@@ -931,8 +945,10 @@ private struct CountdownPhaseView: View {
|
|||||||
// Time's up — final cue and slide on. If the wrist was down the timer may have
|
// Time's up — final cue and slide on. If the wrist was down the timer may have
|
||||||
// stalled; this then fires on the first tick once the app gets runtime again.
|
// stalled; this then fires on the first tick once the app gets runtime again.
|
||||||
didFinish = true
|
didFinish = true
|
||||||
WorkoutHaptic.stop.play()
|
// Buzz only for a boundary that just happened — fast-forwarding through
|
||||||
onFinished()
|
// boundaries that passed while the device slept stays silent.
|
||||||
|
if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() }
|
||||||
|
onFinished(endDate)
|
||||||
} else if remaining <= 3 && remaining < lastPingSecond {
|
} else if remaining <= 3 && remaining < lastPingSecond {
|
||||||
// Once-per-second countdown ping for the final three seconds.
|
// Once-per-second countdown ping for the final three seconds.
|
||||||
lastPingSecond = remaining
|
lastPingSecond = remaining
|
||||||
|
|||||||
@@ -92,13 +92,19 @@ struct ExerciseProgressView: View {
|
|||||||
/// durable echo of our own progress (or of a frame we followed) never triggers a jump.
|
/// durable echo of our own progress (or of a frame we followed) never triggers a jump.
|
||||||
@State private var knownStateIndex: Int
|
@State private var knownStateIndex: Int
|
||||||
|
|
||||||
/// When a remote frame drove us to a page, that page's timer anchors to the *sender's*
|
/// A page reached with a *known* wall-clock anchor counts its timer from that anchor
|
||||||
/// wall clock (`remoteAnchorStart`/`End`) instead of local `now` — so delivery latency
|
/// instead of local `now`. Two sources: a remote frame (the sender's clock, so delivery
|
||||||
/// can't desync the mirror, the way the old read-only mirror counted off the frame's
|
/// latency can't desync the mirror) and a chained auto-advance (the finished phase's
|
||||||
/// anchors. Scoped to `remoteAnchorPage`; any local transition to another page self-anchors.
|
/// computed end, so a ticker that slept through the boundary — wrist-down throttling —
|
||||||
@State private var remoteAnchorPage: Int?
|
/// can't bake its lateness into the next phase's timer). Scoped to one page; a human
|
||||||
@State private var remoteAnchorStart: Date?
|
/// transition to another page self-anchors.
|
||||||
@State private var remoteAnchorEnd: Date?
|
@State private var pageAnchor: PageAnchor?
|
||||||
|
|
||||||
|
private struct PageAnchor {
|
||||||
|
let page: Int
|
||||||
|
let start: Date
|
||||||
|
let end: Date?
|
||||||
|
}
|
||||||
|
|
||||||
private enum PageChangeCause { case auto, remote }
|
private enum PageChangeCause { case auto, remote }
|
||||||
|
|
||||||
@@ -294,13 +300,13 @@ struct ExerciseProgressView: View {
|
|||||||
case .auto:
|
case .auto:
|
||||||
// Rest / timed-work auto-advance: record forward progress, but don't
|
// Rest / timed-work auto-advance: record forward progress, but don't
|
||||||
// broadcast — the watch reaches this point on its own synchronized timer.
|
// broadcast — the watch reaches this point on its own synchronized timer.
|
||||||
clearRemoteAnchor()
|
// The chained anchor for the new page was set by `advance`, not cleared.
|
||||||
recordProgress(for: newPage)
|
recordProgress(for: newPage)
|
||||||
case .none:
|
case .none:
|
||||||
// A human swipe. Swiping back from the first set to Ready wipes the run
|
// A human swipe. Swiping back from the first set to Ready wipes the run
|
||||||
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
// (only the adjacent 1→0 swipe resets — a stray far jump never does); any
|
||||||
// other page records forward progress. Human transitions are broadcast.
|
// other page records forward progress. Human transitions are broadcast.
|
||||||
clearRemoteAnchor()
|
clearAnchor()
|
||||||
if showsReady && newPage == 0 && oldPage == base {
|
if showsReady && newPage == 0 && oldPage == base {
|
||||||
resetExercise()
|
resetExercise()
|
||||||
} else {
|
} else {
|
||||||
@@ -374,9 +380,9 @@ struct ExerciseProgressView: View {
|
|||||||
/// reached by a watch frame, so the lock-screen countdown lines up with the mirror.
|
/// reached by a watch frame, so the lock-screen countdown lines up with the mirror.
|
||||||
private func emitActivity(for page: Int) {
|
private func emitActivity(for page: Int) {
|
||||||
guard var snapshot = liveSnapshot(for: page) else { return }
|
guard var snapshot = liveSnapshot(for: page) else { return }
|
||||||
if page == remoteAnchorPage {
|
if let anchor = pageAnchor, anchor.page == page {
|
||||||
if let start = remoteAnchorStart { snapshot.phaseStart = start }
|
snapshot.phaseStart = anchor.start
|
||||||
snapshot.phaseEnd = remoteAnchorEnd
|
snapshot.phaseEnd = anchor.end
|
||||||
}
|
}
|
||||||
onActivity(snapshot)
|
onActivity(snapshot)
|
||||||
}
|
}
|
||||||
@@ -403,9 +409,7 @@ struct ExerciseProgressView: View {
|
|||||||
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
|
knownStateIndex = frame.phase == .ready ? 0 : max(knownStateIndex, completedSets(for: target))
|
||||||
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
// Anchor the target page's timer to the sender's wall clock, not local now, so the
|
||||||
// displayed countdown matches regardless of how long the frame took to arrive.
|
// displayed countdown matches regardless of how long the frame took to arrive.
|
||||||
remoteAnchorPage = target
|
pageAnchor = PageAnchor(page: target, start: frame.phaseStart, end: frame.phaseEnd)
|
||||||
remoteAnchorStart = frame.phaseStart
|
|
||||||
remoteAnchorEnd = frame.phaseEnd
|
|
||||||
guard target != currentPage else { return }
|
guard target != currentPage else { return }
|
||||||
pageChangeCause = .remote
|
pageChangeCause = .remote
|
||||||
withAnimation { currentPage = target }
|
withAnimation { currentPage = target }
|
||||||
@@ -427,17 +431,15 @@ struct ExerciseProgressView: View {
|
|||||||
if log.sets > setCount { setCount = log.sets }
|
if log.sets > setCount { setCount = log.sets }
|
||||||
let target = base + min(max(0, completed), setCount - 1) * 2
|
let target = base + min(max(0, completed), setCount - 1) * 2
|
||||||
guard target > currentPage else { return }
|
guard target > currentPage else { return }
|
||||||
clearRemoteAnchor()
|
clearAnchor()
|
||||||
pageChangeCause = .remote
|
pageChangeCause = .remote
|
||||||
withAnimation { currentPage = target }
|
withAnimation { currentPage = target }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the remote anchor when a local transition moves us off the remote-driven page,
|
/// Drop the page anchor when a human transition moves us off the anchored page, so the
|
||||||
/// so the next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
/// next phase counts off local `now` (and a swipe back doesn't reuse a stale anchor).
|
||||||
private func clearRemoteAnchor() {
|
private func clearAnchor() {
|
||||||
remoteAnchorPage = nil
|
pageAnchor = nil
|
||||||
remoteAnchorStart = nil
|
|
||||||
remoteAnchorEnd = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
|
/// Inverse of `liveSnapshot`'s page→frame mapping: a frame's phase/set → page index.
|
||||||
@@ -454,9 +456,8 @@ struct ExerciseProgressView: View {
|
|||||||
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
|
/// Build the live-run frame for a given page: phase, the set it pertains to, and the
|
||||||
/// wall-clock anchors the watch counts off. Count-down phases (rest, timed work, finish)
|
/// wall-clock anchors the watch counts off. Count-down phases (rest, timed work, finish)
|
||||||
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
|
/// carry an end anchor; a rep-based work set counts up and leaves it `nil`.
|
||||||
private func liveSnapshot(for page: Int) -> LiveProgress? {
|
private func liveSnapshot(for page: Int, at now: Date = Date()) -> LiveProgress? {
|
||||||
guard let log else { return nil }
|
guard let log else { return nil }
|
||||||
let now = Date()
|
|
||||||
|
|
||||||
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
|
func frame(_ phase: LiveRunPhase, setIndex: Int, end: Date?) -> LiveProgress {
|
||||||
LiveProgress(
|
LiveProgress(
|
||||||
@@ -493,10 +494,10 @@ struct ExerciseProgressView: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func page(for index: Int) -> some View {
|
private func page(for index: Int) -> some View {
|
||||||
let isActive = index == currentPage
|
let isActive = index == currentPage
|
||||||
// Only the remote-driven page carries the sender's anchors; every other page (reached
|
// Only the anchored page (remote frame or chained auto-advance) carries anchors;
|
||||||
// locally by swipe or auto-advance) counts off its own `now`.
|
// every other page (reached by a human transition) counts off its own `now`.
|
||||||
let anchorStart = index == remoteAnchorPage ? remoteAnchorStart : nil
|
let anchorStart = pageAnchor?.page == index ? pageAnchor?.start : nil
|
||||||
let anchorEnd = index == remoteAnchorPage ? remoteAnchorEnd : nil
|
let anchorEnd = pageAnchor?.page == index ? pageAnchor?.end : nil
|
||||||
if showsReady && index == 0 {
|
if showsReady && index == 0 {
|
||||||
ReadyPhaseView(summary: readySummary, onStart: start)
|
ReadyPhaseView(summary: readySummary, onStart: start)
|
||||||
} else {
|
} else {
|
||||||
@@ -524,8 +525,8 @@ struct ExerciseProgressView: View {
|
|||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
anchorStart: anchorStart,
|
anchorStart: anchorStart,
|
||||||
anchorEnd: anchorEnd
|
anchorEnd: anchorEnd
|
||||||
) {
|
) { end in
|
||||||
withAnimation { advance(from: index) }
|
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Rep-based work set — count up; the user swipes left when done.
|
// Rep-based work set — count up; the user swipes left when done.
|
||||||
@@ -547,8 +548,8 @@ struct ExerciseProgressView: View {
|
|||||||
isActive: isActive,
|
isActive: isActive,
|
||||||
anchorStart: anchorStart,
|
anchorStart: anchorStart,
|
||||||
anchorEnd: anchorEnd
|
anchorEnd: anchorEnd
|
||||||
) {
|
) { end in
|
||||||
withAnimation { advance(from: index) }
|
withAnimation { advance(from: index, phaseEndedAt: end) }
|
||||||
}
|
}
|
||||||
adjustPill
|
adjustPill
|
||||||
}
|
}
|
||||||
@@ -616,11 +617,20 @@ struct ExerciseProgressView: View {
|
|||||||
withAnimation { currentPage = base }
|
withAnimation { currentPage = base }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Programmatically move one page right (used by the rest auto-advance), guarding
|
/// Programmatically move one page right when a countdown phase ends, guarding against
|
||||||
/// against overrun if the user swiped away in the meantime. Tagged `.auto` so the page
|
/// overrun if the user swiped away in the meantime. Tagged `.auto` so the page observer
|
||||||
/// observer records progress but doesn't broadcast it (the watch auto-advances too).
|
/// records progress but doesn't broadcast it (the watch auto-advances too).
|
||||||
private func advance(from index: Int) {
|
///
|
||||||
|
/// `phaseEndedAt` is the finished phase's *computed* end — the next phase began then,
|
||||||
|
/// not when this tick finally got runtime. Anchoring the next page there means a device
|
||||||
|
/// that slept through the boundary (throttled wrist-down ticker) shows the same timer
|
||||||
|
/// as one that crossed it on time — and a stack of missed boundaries fast-forwards
|
||||||
|
/// itself, since each chained countdown lands already-elapsed and advances on its own
|
||||||
|
/// next tick.
|
||||||
|
private func advance(from index: Int, phaseEndedAt end: Date) {
|
||||||
guard currentPage == index, index + 1 < totalPages else { return }
|
guard currentPage == index, index + 1 < totalPages else { return }
|
||||||
|
pageAnchor = PageAnchor(page: index + 1, start: end,
|
||||||
|
end: liveSnapshot(for: index + 1, at: end)?.phaseEnd)
|
||||||
pageChangeCause = .auto
|
pageChangeCause = .auto
|
||||||
currentPage = index + 1
|
currentPage = index + 1
|
||||||
}
|
}
|
||||||
@@ -1150,8 +1160,10 @@ private struct CountdownPhaseView: View {
|
|||||||
/// so the remaining time — and the auto-advance at zero — line up across both devices.
|
/// so the remaining time — and the auto-advance at zero — line up across both devices.
|
||||||
var anchorStart: Date? = nil
|
var anchorStart: Date? = nil
|
||||||
var anchorEnd: Date? = nil
|
var anchorEnd: Date? = nil
|
||||||
/// Invoked once the countdown reaches zero (auto-advance to the next page).
|
/// Invoked once the countdown reaches zero (auto-advance to the next page), passing the
|
||||||
let onFinished: () -> Void
|
/// phase's *computed* end so the next phase can anchor at the boundary itself — not at
|
||||||
|
/// whenever this tick got runtime (a device asleep at the boundary ticks late).
|
||||||
|
let onFinished: (Date) -> Void
|
||||||
|
|
||||||
/// Wall-clock window for the countdown. SwiftUI renders the remaining time from this
|
/// Wall-clock window for the countdown. SwiftUI renders the remaining time from this
|
||||||
/// range, and the haptics + auto-advance below are derived from `endDate` rather than
|
/// range, and the haptics + auto-advance below are derived from `endDate` rather than
|
||||||
@@ -1181,7 +1193,9 @@ private struct CountdownPhaseView: View {
|
|||||||
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
endDate = anchorEnd ?? startDate.addingTimeInterval(Double(max(1, seconds)))
|
||||||
lastPingSecond = Int.max
|
lastPingSecond = Int.max
|
||||||
didFinish = false
|
didFinish = false
|
||||||
if haptic { WorkoutHaptic.start.play() }
|
// No buzz for a chained catch-up page whose whole window already elapsed while
|
||||||
|
// the device slept — it advances again on its next tick.
|
||||||
|
if haptic, endDate > Date() { WorkoutHaptic.start.play() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tick() {
|
private func tick() {
|
||||||
@@ -1191,8 +1205,10 @@ private struct CountdownPhaseView: View {
|
|||||||
|
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
didFinish = true
|
didFinish = true
|
||||||
WorkoutHaptic.stop.play()
|
// Buzz only for a boundary that just happened — fast-forwarding through
|
||||||
onFinished()
|
// boundaries that passed while the device slept stays silent.
|
||||||
|
if Date().timeIntervalSince(endDate) < 3 { WorkoutHaptic.stop.play() }
|
||||||
|
onFinished(endDate)
|
||||||
} else if remaining <= 3 && remaining < lastPingSecond {
|
} else if remaining <= 3 && remaining < lastPingSecond {
|
||||||
// Once-per-second countdown ping for the final three seconds.
|
// Once-per-second countdown ping for the final three seconds.
|
||||||
lastPingSecond = remaining
|
lastPingSecond = remaining
|
||||||
|
|||||||
Reference in New Issue
Block a user