Add Live Activity and watchOS support for mapping sessions - #29
Open
agessaman wants to merge 63 commits into
Open
Add Live Activity and watchOS support for mapping sessions#29agessaman wants to merge 63 commits into
agessaman wants to merge 63 commits into
Conversation
Add an ActivityKit and WidgetKit integration for iOS mapping sessions. - show the current wardriving phase, including sending, listening, cooldown, and waiting states - render system-driven countdowns from shared phase deadlines - display up to three heard repeaters with SNR and hop information - include session counters, queue state, zone, GPS, and connection status - provide layouts for the Lock Screen, Dynamic Island, and compact CarPlay presentation - throttle native updates and avoid duplicate Live Activities - mark stale session data and show a final summary when a session ends - keep the integration dependency-free and isolated from existing map presentation logic
The merged Live Activity work introduced the repo's only analyzer issue (unnecessary_brace_in_string_interps). `dev` analyzes clean, so this restores that baseline on the watch-app branch.
Phase 1 of the Apple Watch companion: the target skeleton only. The watch is a mirror-and-remote for a session the iPhone owns, so this ships no session logic — WatchConnectivity, map, node list, and controls follow in later phases. Single-target watchOS app (WKApplication), watchOS 26.0, embedded into Runner via an Embed Watch Content phase so `flutter build ipa` carries it along. Also routes bundle IDs and signing team through MESHMAPPER_BUNDLE_PREFIX and MESHMAPPER_DEVELOPMENT_TEAM, defined once at project level. Both resolve to the previous literals, so nothing changes by default. This exists because a watch app's bundle ID must be prefixed by its companion's: testing on a Personal Team means moving every ID together, which is now one field instead of four targets. Verified: builds for simulator, embeds at Runner.app/Watch/MeshMapperWatch.app with all variables resolved, installs and launches on a paired iPhone 17 Pro / Apple Watch Series 11 simulator pair.
Phase 2 of the Apple Watch companion. The wire is now real in both directions; the wrist UI is still a raw debug dump, replaced by the map in Phase 3. Shared contract in ios/Shared/MeshMapperWatchPayload.swift is compiled into both Runner and the watch target so it cannot drift, with the Dart mirror in lib/services/watch/. WatchSnapshot composes LiveActivitySnapshot rather than re-deriving phase and counter semantics, so both surfaces always agree. Three decisions worth keeping: - Countdowns ship as absolute deadlines, never ticks. The watch renders them with Text(timerInterval:), so a session sends about one update per phase transition instead of one per second. - Colours resolve on the phone. Dart owns the colour-vision palettes, so accessibility palettes work on the wrist with no duplicated code. - The watch sends intents, never state. Every guard is re-evaluated in _handleWatchCommand, so a stale payload cannot cause a transmit. Unlike the Live Activity, the watch receives snapshots even with no session running — otherwise the start button could never be reached from the wrist. Two bugs found by testing rather than review: - WatchSessionManager held its FlutterMethodChannel weakly. setMethodCallHandler makes the messenger retain the handler block, not the channel, so a channel left in an AppDelegate local deallocates and every inbound command was silently dropped. The other channels survive as locals because they only receive; this is the first that invokes Dart from native. - The watch pre-checked isReachable before sending. That flag lags reality — the simulator reported unreachable while still delivering messages seconds later — which turned a stale flag into a refused tap. errorHandler is now the source of truth. Verified on paired iPhone 17 Pro / Apple Watch Series 11 simulators: phone state renders on the wrist (phase, counters, GPS fix, disabled controls with reason), and requestSnapshot round-trips watch → Swift → Dart → ack. 138 tests pass, 27 of them new.
Phase 3 of the Apple Watch companion. The map is Apple's basemap with MeshMapper's data drawn on top: ping squares in the phone-resolved ping colours, repeater pins with a highlight ring for those heard this cycle, optional lines from the fix to each responding repeater, and a fix puck with heading. A countdown pill renders the phase deadline via Text(timerInterval:). MeshMapper's own basemap cannot come along — MKTileOverlay is API_UNAVAILABLE(watchos), so the OpenFreeMap styles, ArcGIS satellite raster, and coverage vector tiles have no route onto the wrist. Only the data layer is ours; a satellite toggle uses Apple imagery instead. The fix is drawn as a custom annotation rather than UserAnnotation, so the watch renders the *phone's* position and needs no location permission of its own. Follow mode recentres on the fix, yields when the wearer pans, and drifts back after 8s. Two bugs fixed while verifying it: the initial `.automatic` camera settle was misread as a pan (suspending follow before the first fix arrived), and the resume deadline was only ever read during a render, so a stationary phone — which sends no updates — would stay unfollowed indefinitely. Initial span is ~3 km rather than ~1 km: wardriving is about what is around you, and the tighter default opened with every nearby repeater off-screen. SampleSnapshot is DEBUG-only behind a launch argument (-MeshMapperSampleData YES). The simulator has no Bluetooth and so can never produce pings or repeaters, which would leave the map permanently empty there. Verified excluded from Release by building the watch target in Release. Known: Apple Maps basemap tiles do not load in the watch simulator — watchOS proxies tile requests through the paired phone and the simulator's companion proxy returns GEOErrorDomain -11. Annotations, geometry, and camera are all verified; the basemap itself needs real hardware.
Phase 4 of the Apple Watch companion. NodeListView shows the repeaters that answered the most recent ping — SNR-coloured dot, name, SNR, and a context line of hop count and distance — with a detail view carrying RSSI, seen count and last-heard time. Both placements read the same view, so this is a presentation toggle rather than two implementations: - sheet: the map keeps a tappable summary bar showing the strongest node inline, opening the full list over the map. - page: the map stays clean full-bleed and the list gets its own tab. The bar is a tap, not the swipe-up originally planned: on watchOS a swipe from the bottom edge is the Control Center gesture, so it would fight the system. Surfacing the strongest node inline also answers the common question — "what just answered?" — with no interaction at all. Rows use standard text styles and never shrink type to hit a row count. The payload carries up to 7; the list renders what fits at the wearer's text size and scrolls for the rest. The map chrome gains a stale badge, so data that has stopped updating can never read as live. Two DEBUG-only launch arguments (-MeshMapperShowNodeSheet, -MeshMapperInitialPage) make specific screens capturable headlessly; the simulator offers no way to tap or swipe. Verified excluded from Release.
Reworks the heard-node model to match what the phone's map actually shows. Wire version 2. The watch was inventing its own idea of "recently responded" from TxPing.heardRepeaters — names, hop counts, RSSI, seen counts. The app's map overlay (_buildTopRepeatersOverlay) shows something different and simpler: up to three rows from the latest ping plus the RX slot, each a dot coloured by which ping type was answered, the hex path hash, and the SNR. Three corrections: - Hex ID is the identity, not the name. Path hashes are 1-3 bytes, so a short ID often maps to several repeaters. Names are resolved only through indexByHexPrefix, which drops any prefix owned by more than one repeater -- a confidently wrong name is worse than none. The watch always shows the hex and treats a name as a secondary hint. - No hop counts anywhere. _updateTopRepeaters is fed directRepeaters and multiHopEvents are explicitly excluded, so multi-hop was never part of this surface. - The RX slot trails the top three rather than competing on SNR, matching the overlay's distinct trailing row. Layout follows the phone: Top Heard hard against the upper-left corner (drawing into the top safe area, which is free because the system clock is right-aligned), countdown pill bottom-right. The bottom summary bar is gone, superseded by the box. Colours come from the same OverlayPingType mapping the map uses, resolved on the phone, so colour-vision palettes carry across unchanged.
…orner Two placement and legibility fixes on the watch map. The countdown sat well clear of the bottom edge because the overlay still honoured the bottom safe area while the map beneath it ignored it. The overlay now ignores both edges, so the two corners are actually corners. Top Heard is sized for the worst case rather than the sample case. A 3-byte zone yields six-character path hashes, and four of those at full size ran the box across a 40 mm screen. Row type now shrinks with ID length exactly as RepeaterIdChip does on the phone (11/10/9 pt for 2/4/6 characters), rows are pinned to a single line, and the box is held to a fixed type size: it is a HUD, and at large accessibility sizes it would otherwise swallow the map. The scrollable detail list is where the wearer's text-size setting is honoured. Both overlays move from flat 70% black to a blurred material. The flat panel let bright basemap labels bleed through and fight the SNR digits — visible as a road label crossing the fourth row. Blurring removes the competing detail and matches the platform's own overlay treatment. Verified at the worst case — four six-character IDs on a 40 mm SE — via a new DEBUG launch argument, -MeshMapperLongIds YES.
Both overlays were clipped by the display curvature on a real watch. The simulator renders a flat rectangle and never showed it — .ignoresSafeArea was reaching for corners that physically do not exist. Rather than nudging insets, the two overlays become one panel across the bottom of the map, inside the safe area, with no corner to lose: - A depleting bar across the top, draining right to left, with the remaining time beside it. Fed by phaseEndsAt and a new phaseDurationMs, both absolute, so the bar is correct between updates and correct when the app opens midway through a phase. CountdownTimerService gains a durationMs getter and the provider identifies the owning timer by matching end times. - Heard rows in two columns when they fit, one when they do not. ViewThatFits decides by measurement: a 3-byte zone's six-character hashes plus SNR cannot fit two columns on 40 mm, and the hex ID must never truncate — it is the repeater's identity. Two layout traps worth recording. A Rectangle rule is a greedy child and stretched the panel to its cap; spacing replaces it. And .frame(maxWidth:) is expansive rather than merely limiting, so capping the panel made it that wide always — only the phase title, which can run long, carries a ceiling now. The translucent material is kept, and the whole panel remains the tap target for the detail list.
…panel The label rides on the timer bar instead of sitting beside it, with a shadow so it stays readable over both the filled and empty parts of the track. A separate column cost width permanently and left the phase title cramped. The camera now places the fix in the middle of the band between the top of the display and the top of the panel, rather than the middle of the display, so the puck is no longer pushed down behind the panel. The panel measures itself through a preference key, so this holds however tall the panel gets. Also tracks the rendered region from onMapCameraChange. Without it a Digital Crown zoom was discarded on the next follow update, since every recenter reused the originally requested span. Known incomplete: the offset under-shifts. Instrumentation on a 46 mm simulator showed panel=54pt, view=159pt, frac=0.34, latSpan=0.03 — the fraction is right, but two denominators are wrong. viewHeight measures the ZStack (159pt) while the map draws into the safe area it excludes (~242pt), and context.region.span reports the requested span rather than the visible one. Deriving both from context.rect (MKMapRect) is the fix.
Two changes to the map page, both driven by what the panel actually measures rather than by a guess. Bar placement now differs by screen. A 46 mm watch has 184 pt of panel width and a 40 mm one has 138, so the large screens set the phase title and the countdown either side of the track and leave the bar as a pure gauge, while the small ones keep the single label riding on the bar. The column gutter widens on the roomy sizes too, which ViewThatFits will still veto down to one column if six-character hashes need the space. Placing the fix is now a question for the map, not a calculation. The previous offset under-shot because it compared the panel's height to the enclosing stack's, and that stack is not the map: SwiftUI reports the map's frame as 159 pt tall on a 248 pt display, yet it draws to every edge. No measurement of the view hierarchy predicts where a coordinate lands. MapReader's proxy answers directly, so the camera translates by the difference between the fix and whatever sits at the target point, then a deadbanded correction on each camera change absorbs the first render and any Digital Crown zoom. Verified: the puck settles at 78.75 pt against a panel top of 158 on 46 mm, and 47.75 against 96 on 40 mm — centred in the band, as asked, and stable across frames. Worth remembering: the CGRect preference keys must ignore empty values. Every sibling subtree contributes the default, so taking nextValue() unconditionally let a later .zero overwrite the real frame — the measurements read as zero until reduce learned to skip them.
…y curve Three changes, implemented by Codex against a spec and verified here on both simulator sizes. The phase title and the countdown both ride the bar again, at opposite ends of one overlay. Splitting them by screen size was wrong: it moved "Listening" permanently to the left of the track on large watches and left the right end of the bar empty, which read as the clock having disappeared. One treatment now serves every size. The panel is narrower and sits 4 pt from the bottom edge. It may descend into the bottom safe area only in exchange for horizontal clearance, because that safe area is what the display's curvature costs: modelling the corner as a circle of radius R, a panel whose bottom edge is g from the edge needs R - sqrt(2Rg - g^2) of inset, plus 4 pt because the reported inset is a lower bound on the real glass. watchOS exposes no corner radius, but its bottom safe-area inset is the clearance a full-width element demands, which is that radius. The 46 mm reports 36 and the 40 mm 19, so the large watch narrows nearly twice as much — the asymmetry that was asked for, arrived at rather than assumed. Top Heard is two columns everywhere now, including 40 mm, with the type size solved from the width actually available rather than picked from a hash-length ladder. Only a six-character zone on the smallest screen falls back to one column, and it now does so at the 9 pt cap instead of inheriting the two-column floor. Measured, in points, with the panel top and the fix both verified stable across frames: 46 mm safeBottom 36 inset 23.5 panel 157.5 wide at y=190 font 9.8 40 mm safeBottom 19 inset 11.3 panel 135.5 wide at y=144 font 7.8 40 mm six-char inset 11.3 panel 135.3 wide at y=115 font 9.0 Three measurement traps cost a build each and are worth remembering. Reading the container's width to compute padding applied to that same container is a feedback loop — the 40 mm reported itself 169 then 173 pt wide on a 162 pt display, and the panel landed 2.3 pt from the edge instead of the 9.3 it had just computed; WKInterfaceDevice.screenBounds is static and cannot feed back. A GeometryReader in the map's background reports no safe area, because the map ignores it. And `.ignoresSafeArea()` on the reader itself reports none either, since it measures its own expanded region — plain is correct here, and the first value is latched so the panel can never perturb the number that positioned it. The corner model still needs confirming on hardware: the simulator renders a flat rectangle and cannot show a clip.
Placement and clearance gaps are now separate constants. The panel sits 8 pt from the bottom edge, but its horizontal inset is still evaluated as if it sat at 4 — otherwise the geometry would hand back roughly 12 pt of width on the 46 mm, undoing the narrowing that was the point. Keeping the lower position as a clearance floor is also the conservative direction for curvature the simulator cannot show. Measured: both insets unchanged at 23.51 and 11.34, both panels 4 pt higher, and the fix recentred with them — 92.75 pt against a 93.0 target on the 46 mm, 69.75 against 70.0 on the 40 mm. Also verified the six-character worst case on the 46 mm, which had only been checked on the 40 mm: two columns at ~8 pt, no overflow. The 40 mm remains the sole size that falls back to one column, at the 9 pt cap.
…d phases Both gaps now scale with the measured corner radius rather than being fixed. A panel sitting higher needs less horizontal clearance, so height buys width — the trade this deliberately refused last commit, when the ask was narrower-and-higher and only the height part had arrived. The ask now includes the width, because six-character hashes on a 46 mm were down at 8 pt. The 4/19 and 8/19 ratios are calibrated from the 40 mm, the one size confirmed good on hardware, so it reproduces its numbers exactly by construction — inset 11.338, content 123.324, font 7.825, verified unchanged. The 46 mm gains 11 pt of width and sits 15 pt off the bottom, taking six-character IDs from 8.0 to 8.81 pt. Width still binds there rather than the hash-length ladder's 9 pt ceiling. A lapsed deadline no longer claims its phase. The title was never wrong — the phone sends "Listening…" while the RX window runs and "Next ping" while the auto-ping timer does, matching ping_controls — but the watch rendered whatever it last heard forever, so a passed deadline with no newer snapshot left "Listening" asserted over an empty track. That is the state Adam photographed. Titles with a future deadline are unchanged; titles with no deadline at all stay full strength, since "Device disconnected" and "Waiting for GPS" are states rather than countdowns and remain true until replaced; a title whose deadline has passed now dims to 45%, reading as last-known rather than current. The reason this took until now to surface is that SampleSnapshot hardcoded "Listening", so no screenshot ever showed a wait phase. -MeshMapperSamplePhase listen|wait|lapsed fixes that gap. Verified all three render correctly, and that it and the three existing launch arguments are absent from the Release binary.
Phase 5. The transport and all guards already existed — `_handleWatchCommand` revalidates on arrival and the wire carries `canStartStop`, `canManualPing`, `isSessionActive`, `blockedReason` and the cooldown deadline. What was missing was the surface. Start/stop, and manual ping behind a two-stage confirm: the first tap arms the button for three seconds, the second sends. A wrist bump must not be able to transmit, and disarming needs no round trip. `WatchSessionClient` now tracks the in-flight command so a tap shows work happening, and drops a reply that a newer tap has superseded — otherwise an old answer lands under a fresh action. Silent refreshes stay out of that state; the wearer never asked for them. Three defects found by putting it on both simulator sizes: A cooldown is the only unavailability the phone reports with **no** `blockedReason` — `_buildWatchControls` sets one for "Not connected" and "No GPS fix" only — so the ping button sat dead and unexplained for fifteen seconds, which is precisely what this phase exists to prevent. It now counts down on the button face from the absolute deadline already on the wire, so it stays right without further snapshots. The page title pushed `blockedReason` below the fold on a 40 mm, hiding the one line that explains a disabled button. Dropped: the buttons name themselves. The reason also finished flush against the bottom edge, which is the class of bug that clipped on real hardware twice, so the content now keeps clear of it. Disabled buttons rendered inconsistently — a disabled green `borderedProminent` desaturates to a pale grey that reads as tappable, while the accent-tinted one went dark. Both grey out now. Also fixes a debug affordance that never worked: `MeshMapperInitialPage` was assigned in `onAppear`, and a `.verticalPage` TabView ignores a selection change made that late, so every headless capture silently landed on the map. It is the state's initial value now. Node-list default is its own page, chosen for the room it gives the rows. Sample controls gain `idle|blocked|cooldown` alongside `active`.
Three faults in one place, reported from the wrist as "the initial zoom level was very high (multiple states)". The opening view was never the default. `camera` starts `.automatic`, which fits every annotation in the snapshot — continental with repeaters spread wide — and `noteRenderedRegion` then adopted that span as though the wearer had chosen it, so one auto-fit poisoned the zoom for the rest of the session. Rendered spans are now ignored until `programmaticCenter` exists, the same signal that already distinguishes our own camera updates from the wearer's. The default is 500 m rather than 3.3 km. The old comment argued the wide span was deliberate for wardriving; that reasoning was mine and the wrist disagreed, so it is gone rather than left contradicting the code. Zoom now survives relaunch. It was `@State`, so every launch discarded it. It lives in `WatchSettings` with the other preferences, clamped to 0.0005...0.5 on both read and write — persistence turns a stray Crown flick into a permanent state, and the clamp is what stops a remembered preference becoming a trap. Absence is distinguished from zero, because `double(forKey:)` returns 0 for a missing key and would have opened every fresh install at the 55 m minimum. Persisting only on a material change (>1%), since every follow update raises a camera change and writing an identical value would invalidate the observable and re-render the map for nothing. Verified on a fresh simulator container: opens at street level and stores 0.0045, which is proof the `.automatic` span was not adopted — that would have stored a value orders of magnitude larger.
…, add the icon
Four wrist reports.
**The map flew across the north Pacific on launch.** Two faults stacked.
`recenterIfFollowing` always animated, so the first placement was a 0.25 s
flight from `.automatic`'s arbitrary opening position to the fix, dragging
tile loads the whole way. The first placement is now a cut; later ones
still animate, which is right for small follow nudges.
Underneath that, `.automatic` settles *after* our first request, centred on
the annotation cloud — measured 372 m from the fix — and `noteCameraChange`
read that disagreement as the wearer panning. It suspended following for
eight seconds and overwrote its own expectation, so our region landing then
looked like a *second* pan:
[pan] SUSPEND dist=371.9 center=47.611891,-122.323025 expected=47.6122,-122.3181
[pan] SUSPEND dist=371.9 center=47.6122,-122.3181 expected=47.611891,-122.323025
Nothing counts as a pan now until MapKit has confirmed a centre we asked
for. Zero suspensions at launch, down from two, and the fix lands 1.9 pt
from target on 46 mm and 0.25 pt on 40 mm. This also explains a transient
recenter button I dismissed as mid-animation two days ago, and it means the
placement regression in `a846153` was mine to catch and I did not — I said
the puck looked centred without measuring it. It was 57 pt low.
**Manual ping was offered where the phone would refuse it.** The watch gate
was `isConnected && hasGpsLock` plus cooldown; the app's own Send Ping
button requires twelve conditions. `_buildWatchControls` now mirrors that
set exactly, through the same `manualPingValidation` getter the widget uses,
rather than a second implementation of the policy. `blockedReason` gains
the app's own words for the two states it names, "Offline Mode" and
"Passive Only".
**Ping markers are circles.** They were squares under a comment claiming
they matched the iOS map, which is how it survived — `_CoverageMarkerPainter`
draws a filled circle with a white border. Mirrored at wrist scale, minus
the shadow, which would cost a blur each for up to sixty markers.
**The watch had no icon** because its `AppIcon.appiconset` held a
`Contents.json` expecting an image and no image. Copied the 1024 pt iOS
icon in; `AppIcon` now compiles into the watch's `Assets.car`, which
previously carried only `AccentColor`.
…orting Three wrist reports, plus two defects found verifying them. **The command handler's guards were weaker than the button's.** Its manualPing case checked only connection, GPS and cooldown, then called sendPing — while `_buildWatchControls` mirrored the app's twelve conditions. So a wrist tap could reach the radio in a zone where TX is not permitted, with nothing but sendPing's own checks between. That contradicts the contract stated a few lines above it: every guard is re-evaluated because a stale payload must never cause a transmit. The condition set now lives once, in `_manualPingAvailability`, returning availability and reason together. One caller decides what the wrist offers; the other decides whether the radio transmits. Duplicating that policy is exactly how the two drifted apart. **"Stopping…" jumped to the right edge.** A SwiftUI ProgressView takes the horizontal slack a stack offers it, so the spinner shoved the label aside the moment a command went pending. Spinners are leading overlays now, outside the layout that centres the label — feedback should not move the thing under the wearer's thumb. **A dead transport error sat under the button.** "Payload could not be delivered." is WatchConnectivity's wording and it stayed on screen indefinitely, reading as current state rather than one past action. Refusals expire after six seconds, and the delivery-failure and unreachable codes now say "iPhone didn't respond, try again". Refusals from the phone stay verbatim — those are already written for people, and rewording them would put the watch's guess above the phone's statement. No retry: a send whose delivery is uncertain must not be repeated, or a ping goes out twice. Two defects caught in review, neither reported: The cooldown label put two Text views in a `Group`, which applies each modifier to every child — so `maxWidth: .infinity` went to the words and the timer separately and threw them to opposite ends of the button. Verified on screen before fixing. It is one HStack now. The reason ladder fell through to "Another operation is in progress" even when the ping *was* allowed, which would have printed that under two working buttons. Reason is nil unless a refusal is actually happening.
Start and stop worked from the wrist but showed "iPhone didn't respond, try again" every single time. The command was fine; the acknowledgement was late. `relayCommand` replies only when Dart's future resolves, and `_handleWatchCommand` awaited the entire action — `startSession` awaits `toggleAutoPing`, which makes an API session check and drives BLE. That outruns WatchConnectivity's reply window, so the watch's error handler fired on every success. Guards still run on arrival, unchanged: admission is decided synchronously and refusals still travel in the reply. What changed is that the action itself is no longer awaited before replying. Outcomes were never the reply's job anyway — `isSessionActive`, the phase and the ping colour all reach the wrist through snapshots. That leaves failures that only appear later, and manual ping is the case in point: it is refused inside `_checkSessionBeforeAction`, a *server* call, so no local gate can predict it and the wrist got a bare "Ping failed". `WatchHapticCue` already existed for events of this class, so it gains an optional message; a failed action emits a unique-ID failure cue and schedules a snapshot. The watch shows it through the same expiring path as a refusal — one presentation for "the phone says something went wrong", not two. Cue IDs are deduped against a bounded cache because immediate messages and application context can deliver the same cue in either order. `_checkSessionBeforeAction` had `result.reason` and `result.message` and discarded both. It now keeps them, so a refused ping says why, and `zone_full` reuses the existing "Passive Only" wording rather than inventing a third phrasing for one condition. Wire version deliberately unchanged: the cue field is optional and both sides ship in the same app. Unverified end to end — reproducing it needs a phone doing real BLE work, which the simulator cannot do. The reasoning and the gates are sound; the wrist is the proof.
Start and stop kept reporting "iPhone didn't respond, try again" while
working. A device console capture ended the guessing:
[WATCH] sendMessage(requestSnapshot) failed: ...device is not reachable.
[WATCH] sendMessage(startSession) failed: Payload could not be delivered.
`sendMessage` needs the counterpart app reachable, which for the phone
means roughly foreground — not the normal case when someone taps their
watch. WatchConnectivity delivered the payload and the phone acted on it,
but the reply could not return, so the watch reported `deliveryFailed`
after every success.
The previous fix, replying on admission rather than completion, was aimed
at reply latency. Latency was never the cause. Both of my diagnoses came
from the symptom; only the logged error settled it.
Commands and refresh requests now go by `transferUserInfo`: queued,
survives unreachability, wakes the counterpart, and has no reply to fail.
That is affordable only because outcomes and refusals already return as
snapshots and failure cues. `requestSnapshot` gains the most — it used to
fail outright with "not reachable", exactly when a refresh is most wanted.
Deliberately no opportunistic `sendMessage` and no retry on failure:
`deliveryFailed` is reported for payloads the phone *did* process, so a
fallback resend would transmit twice.
**Queued commands must expire.** A transfer can arrive whenever the phone
next becomes reachable, and a ping that fires minutes late is attributed
to where the vehicle now is rather than where it was. Commands carry
`issuedAtMs`; anything older than 30 s is refused before reaching
`_handleWatchCommand`. The ID is remembered first, so redelivery cannot
retry it later. `requestSnapshot` is exempt — a late refresh is harmless —
and a missing timestamp is still accepted, for watches running the older
build.
`pendingCommand` was cleared by the reply that no longer exists, so it now
clears on the next snapshot with a 10 s backstop; a spinner that never
stops is worse than none. `WatchCommandAck` is removed rather than left
describing a protocol we no longer speak.
Starting a session from the wrist did nothing, while stopping produced a cooldown that passive mode never creates. `_handleWatchCommand` called `toggleAutoPing(_autoMode)`, but `_autoMode` defaults to Active and is only assigned inside `toggleAutoPing` when a mode actually starts. So until a mode had been started *on the phone*, the wrist started Active — which in Adam's passive-only region is the one mode forbidden there. His Live Activity had been reporting this all along: the "circle with a line through it" is the `txBlocked` phase, which fires on exactly `(_autoMode == active|hybrid|targeted) && !txAllowed`. Pressing Passive on the phone set `_autoMode`, which is why every wrist toggle worked afterwards. The phone never hit this because each of its buttons passes an explicit mode. Only the wrist inherited an implicit one, and the default happened to be the forbidden one. `_resolvedWatchSessionMode` now decides: a running session keeps its own mode, so the wrist stops what it started; otherwise a region that forbids TX resolves to Passive; otherwise the wearer's last choice stands. The button says which mode it will start — "Start Passive" — because a wrist control that silently picks a mode is only safe while the guess is right. That label reads from the same resolver as the action; sourcing it from the ambient `_autoMode` would have traded a silent wrong action for a visible lie. Only the watch payload's `mode` changed: the Live Activity keeps `_liveActivityModeTitle`, since it reports the session that is running rather than the one a button would start.
"The design of the live event panel on the watch and iphone leave a lot
to be desired. We have better design elements in the app we should
repurpose." The elements worth repurposing are the ones now signed off on
the wrist: a depleting countdown bar, and rows of hex identity with a
ping-type dot and a quality-coloured SNR.
Most of the gap was data, not styling. `ContentState` carried
`phaseEndsAt` but no duration, so a progress bar could only be full or
empty. `HeardRepeater` was `{id, name, snr}` with no ping type and no
colour, so every dot was painted the same grey-teal and the distinction
between a discovery answer, a flood answer and an RX packet — which the
map overlay is built around — could not be drawn at all. Nothing carried
the last ping's outcome, so an unanswered ping, a real negative result
when mapping coverage, looked identical to a cycle that had not reported
yet. And the extension hardcoded three colours, quietly ignoring the
colour-vision palettes that the watch honours for free.
So the wire gains `phaseDurationMs`, `pingColor`, and per-repeater
`typeColor`/`snrColor`, all resolved on the phone through the same
helpers the watch already uses rather than a second set. Colour policy
stays in Dart, where the palettes live.
Layouts, per surface's real constraints: the lock screen shows hex *and*
resolved name, because that is what the larger display is for; the watch
small family shows hex only, since the hash is the identity and there is
no room for more; the island's minimal presentation carries the outcome
colour, since one mark should be the most valuable one.
A lapsed deadline dims its title to 45% and drops the countdown, matching
the rule the watch already follows — these surfaces can sit on a stale
state for a long time, and neither should keep asserting a phase it can
no longer vouch for.
Reviewed as rendered pixels, not as code. The content views take a plain
`ContentState`, so `ImageRenderer` can draw them headlessly — nine images
across three states at each surface's real width. That caught what
reading could not: the metrics sat on the third repeater row's baseline,
so `91CE -8.7 dB TX 42 RX 318` read as one line and the session totals
looked like properties of a repeater. They now share the badge row,
taking the space back from `phaseDetail`, which on the lock screen only
restated the bar above it. The detail stays in the payload — several
phases carry information the bar does not, and the island's centre region
is the place for it.
Session-end summary is deliberately not here; it is the next round.
Adam, on the render: "Don't tint the bar. That's too much of an error telegraph for a common case." He is right, and the mistake was mine. An unanswered ping is the normal outcome in thin coverage — it is the thing being mapped, not a fault. Filling the whole progress bar red made an ordinary result look like a system failure, and in doing so left nothing louder for actual errors. The bar and the Dynamic Island keyline now take a neutral accent, and the compact-leading glyph follows the phase rather than the last ping. The outcome stays legible where it belongs: the outcome dot on the small and minimal presentations, the coloured dot beside "Nothing heard", and the per-repeater type dots. None of those were made louder to compensate — the point is that a routine negative result should be available, not announced. The watch's own bar has the same tint and arguably the same problem, but that surface was signed off on hardware, so it is asked about rather than changed here. Verified by re-rendering all nine states through the harness.
"The teal passive pings aren't being displayed as dots on the map or they
are being rendered as purple rx dots."
They were never displayable. `buildPings` took only `txPings` and
`rxPings`, and `_buildWatchGeo` passed exactly those — so the builder's
`pingColor('disc', …)` teal branch and its `trace` branch were
unreachable code, and RX purple was the only non-TX colour the watch
could draw. Green was fine: it is on the path that runs.
The phone draws these from sources the watch was never handed:
`discLogEntries`, where success is `discoveredNodes.isNotEmpty`, and
`traceLogEntries`, which carries `success` outright. Both now reach the
wire, with the phone's own success rules rather than new ones.
The cap needed rethinking with four sources. It applied to a list built
as "all TX, then all RX", which with discovery added could have kept
sixty TX markers and dropped every teal one — the same bug wearing a
different hat. Candidates are now sorted newest-first across all types
before the cap, so history thins evenly instead of a category vanishing.
Also mirrors the phone's multi-hop rule, which is the other half of what
he saw: a TX answered only through multi-hop draws as an RX marker,
because that is what it evidences — the packet returned, but not
directly. `pathHops == null` marks a direct echo.
Four types, four colour rules, and until now nothing asserted that a
discovery ping ever reached the wire at all — which is exactly how a
whole category went missing unnoticed. The tests do that now, including
that the cap starves no single type.
Not fixed here, and reported separately: RX-only repeaters never get the
current-cycle ring, because `heardIds` is built from `_topRepeatersOverlay`
alone and omits `_rxOverlaySlot`. That is the repeater pins, not the ping
markers, so it does not belong in this change.
A 71-minute walk cost ~40% of Adam's watch battery — about 34%/hour, which makes the app useless for the long drive it exists for. Two patterns account for the obvious waste, and neither was measured here: watch power cannot be instrumented from this machine, so these are the known-expensive things removed, not a proven culprit. A real walk is the only test. **Always-On was unhandled.** Phase 7 planned it and it was never built, so for most of that walk — wrist down, app frontmost — watchOS was rendering a live MapKit view with annotations. MapKit is the most power-hungry thing on the device, and none of it is legible at reduced luminance. The dimmed state now removes that subtree entirely rather than covering it, and stops driving the camera: no recentring, no corrections, no animations until full luminance returns. **A 1 Hz TimelineView redrew the panel over the live map** for the whole session, compositing translucent material every second. Most of it bought nothing: `Text(timerInterval:)` already updates itself natively, and a depleting bar can be one linear animation over the remaining phase rather than thousands of view updates. The Live Activity's bar had the same timeline and the same fix. No 1 Hz timeline remains anywhere. Always-On also needed its own layout, which only became apparent once it could be seen. Reusing the map's overlay panel left a small card pinned to the bottom of a black screen at map-overlay type size — and then the phase title truncated to "List…" on a 40 mm, hiding the one thing a dimmed glance is for. The dimmed view now spends the space it actually has: title at 18 pt wrapping rather than truncating, countdown at 24 pt beneath it, Top Heard full width below. The progress bar is gone from that state — beside an explicit countdown it was duplicated information and another compositing pass. The countdown reads "<1 min" / "3 min" there, because Always-On updates about once a minute and a seconds figure would be silently up to a minute wrong. `MeshMapperForceDimmed` is kept, not scaffolding: Always-On cannot be entered in the simulator and otherwise needs a wrist-down device, so without it this surface goes back to being unreviewable — which is how it shipped bottom-pinned and truncated in the first place.
… glanceable Three things from real-device use. **The bar stuttered in "ping skipped" mode.** `phaseKey` included the phase and its title, and `.task(id:)` restarted the drain whenever either changed. In skip mode the phase flips between waiting/"Next ping" and skipped/"Ping skipped" while the *same* auto-ping timer runs to an unchanged deadline — so each flip cancelled the animation, snapped the fill back to its true fraction and started again. The bar was reporting a change of wording as a change in time. It keys on the deadline and duration alone now; the title is a caption over the drain, not part of it. **A bar that cannot be refreshed should not pretend to move.** On the iPhone's always-on lock screen, refreshing about once a minute, the animated fill rendered frozen mid-drain — which reads as a stalled session, worse than showing nothing. At reduced luminance the Live Activity now draws the track alone and lets the countdown carry the state, a coarse number being honest where a stopped bar is not. Adam's rule, worth keeping: an element implying continuous motion must not be drawn where the refresh rate cannot deliver it. **The panel's text was too small to glance at.** Raising it buys width, because the curvature clearance needed at a given height falls as the panel moves up the curve, and width buys type size through the existing solver. This re-couples placement to clearance, which `bf6f7ef` deliberately decoupled — that was right when he wanted the panel narrower, and this is right now that he wants it legible. His call both times. 46 mm gap 18.9 inset 8.3 panel 191 font 11.0 45 mm gap 18.4 inset 8.2 panel 182 font 11.0 40 mm gap 10.0 inset 6.3 panel 149 font 8.7 The ladder cap rose a point too, since width alone was no longer the binding constraint. Mirroring the phone's `RepeaterIdChip` sizes was the original reason for it, but a watch is read at arm's length in motion. Worth flagging: the panel is now nearly as wide as it was before he asked for it narrowed, though sitting much higher. The clearance arithmetic says that is safe — at 19 pt up the curve the corner needs only ~4 pt of inset — but the hardware confirmation was taken at the previous geometry, so this specific combination is unverified on glass.
"On the map our location doesn't [stay] fixed in the center with the map moving around, instead we move on the map and then recenter." Two decisions collided. The phone withholds geo updates until the fix moves 15 m, so nothing moves between snapshots and each one lands as a single large step. And the camera animated over 0.25 s while the fix annotation — anchored to a coordinate — moved the instant the snapshot arrived. So the puck jumped ahead and the map slid after it, which is precisely the sensation of moving across the map and being chased. Worst where he noticed it: at walking pace that is one lurch every ~11 s. At 30 mph the same threshold fires every ~1.2 s and reads as continuous. Automatic follow updates and placement corrections now cut, so the camera moves in the same frame as the fix and the puck stays where it is while the world steps beneath it. An explicit recentre tap still animates: it is rare, the wearer asked for it, and the motion shows what their tap did. Nobody asked for a follow update, so nothing should appear to move except the world. Two alternatives were considered and deliberately not taken, both recorded: lowering the 15 m threshold buys smaller steps with more radio wakeups, and interpolating between fixes would pan continuously but invents position data and reinstates the continuous animation `f15c93e` removed. Either is available if stepping still distracts on a drive — after tomorrow's battery numbers, not before.
"The countdown timer in the live [activity] is not positioned at the end of the bar. It would be nice to move it a bit closer to the end of the bar as it is in the watch app." Measured from a render rather than guessed: the number's right edge sat 8.7 pt inside the track on the lock screen and 8.3 in the island. Because the cap is rounded, the eye measures to the curve, which makes that gap read as larger than it is — the number looked adrift in the dark part of the track rather than anchored to its end. The two ends were being inset equally, but they are not symmetric in effect: the title begins against a straight fill edge while the countdown ends against a curve. The inset is asymmetric now, and the gap measures 4.0 pt on the lock screen, 3.7 in the island and 4.0 on the small family. The spacer between title and countdown is untouched — it is what guarantees the title truncates before the two can collide, so buying room from it would trade one defect for a worse one.
…entity Three findings from the branch review, all traced before being believed. **A synthetic timestamp defeated the dedupe and lied on screen.** Every heard row carried `at: now` — the moment the payload was built — so once Top Heard held anything, each rebuild produced a different fingerprint and the 2 s throttle became the only brake: roughly 2,100 context updates across a 71-minute session where near-zero were intended. Excluding `updatedAtMs` from the fingerprint had achieved nothing. It was also displayed: Node Detail's "Heard" time read as now, always. Rows now carry when their set last changed, tracked separately for Top Heard and the RX slot because multi-hop updates can move one without moving the other. The manual-cooldown deadline serialises the timer's own `endTime` rather than being reconstructed from two `DateTime.now()` reads whose jitter alone changed the fingerprint. **The cheap check ran after the expensive one.** `_flush` built the whole geo payload — merging and sorting up to 2,000 ping candidates, resolving colours and distances, evaluating the twelve-condition ping gate — then serialised it, and only then compared the fingerprint and usually threw it away. Five countdown timers tick at 500 ms into the scheduler, so a quiet session did that twice a second. Urgency is now decided from a small scalar projection first, and a flush inside the throttle window reschedules without building anything. Worse, none of it was gated on owning a watch. `isSupportedPlatform` only asked whether this was iOS; native refused the payload at `isPaired` / `isWatchAppInstalled`, but after Dart had built, encoded and crossed the method channel. Someone with no Apple Watch paid all of that for a payload that was discarded. Native now publishes its availability and the scheduler does nothing without it — and because `sessionWatchStateDidChange` republishes, a watch paired after launch starts working without a restart. **Links compared incompatible identities, so none had ever drawn.** `linkedRepeaterIds` and the heard IDs carry path hashes; `WatchRepeater.id` carried the API database ID and `hexId` never reached the wire. The watch compared the two exactly, so no link line has ever appeared and the current-cycle ring almost never fired. Both identities now travel, and matching resolves a path hash as a unique hex prefix — ambiguous prefixes draw nothing, because a line to the wrong repeater asserts a relationship that does not exist. The RX slot joins the highlight set, which was the item deferred from the ping fix. Nine tests cover the parts that were blind: timestamp stability across rebuilds, throttling before the build, pairing transitions, prefix links, ambiguity, and RX-only highlighting.
Five remaining review findings. **Stop was silently dropped while a session was starting.** Admission checked `_autoPingEnabled` but not `_autoPingStarting`, and during Start's awaited session check the first is false while the second is true — so a Stop arriving in that window was treated as "already stopped" and discarded, after which the session came up anyway. Start in that window is genuinely idempotent and is still accepted as a no-op; Stop is not, so it now refuses with "Still starting — try Stop again." No deferred queue: a refusal the wearer can act on beats hidden ordering they cannot see. **An idle watch claimed to be preparing a session.** The shared phase resolver maps "no session" onto Starting, which is right for the Live Activity — whose builder only runs during a session — and wrong for the watch, which is always present. A connected, GPS-locked, idle watch said "Preparing session…" indefinitely, including right after Stop. The watch projects that fallback to a new idle phase, "Ready / No session running"; the Live Activity still calls the shared resolver directly and is unchanged. **The outcome colour only ever read TX history**, so a Passive session showed whatever TX last did, and a multi-hop-only TX reported success while the map marker beside it drew RX purple — the same event described two ways. It now follows the newest event across all four histories and applies the map's multi-hop rule. **Staleness never invalidated the view.** `isStale` compared against `receivedAt` with nothing changing at the 90-second boundary, so on a durable phase a dead link could look current indefinitely. The boundary is an event now: one cancellable task per snapshot, not a poll — this is the app whose battery we spent the day cutting. **A failure cue replayed after a watch restart**, because the phone never cleared it and the watch deduped IDs in process memory only. The phone drops a cue once native accepts it, and the watch ignores anything undated or older than 30 seconds. Either half alone leaves the hole open.
At full luminance the readout sits below the navigation bar that hosts the map/readout toggle. Hardware hides that bar in Always-On, so the same surface rendered several points higher — the shift Adam saw when comparing the two. Rather than encode which way it moves, the readout now measures the full-luminance bar and restores only what disappears: `max(0, latched - current)`. Equal insets give zero and nothing moves; a missing reference is deliberately a no-op, the same stance the panel takes when its own measurement is absent. Two details that are easy to get wrong and were both measured, not reasoned. The inset settles *upward* — 28.0 then 35.5 on 40 mm, 44.5 then 59.75 on 46 mm — as the bar grows to fit its toolbar item, so the reference is the largest value seen, not the first. First-nonzero would have under-corrected by 7.5 pt and 15.25 pt respectively, which reads as "nearly fixed" rather than as a bug. And the map reports a different inset again (47.5 and 62.0) because it extends beneath the top chrome, so only the readout may set the reference; borrowing the map's would push the readout down past where it belongs. The top rule therefore differs from the bottom's first-nonzero latch on purpose. The bottom value drives padding and can feed back into the measurement that produced it; this one drives a visual offset that cannot, so tracking a maximum is safe here and not there. Full-luminance readout measured unmoved to 0.00 pt on both sizes. The Always-On half is verifiable only on hardware.
The previous correction moved the readout with `.offset(y:)`, which repositions without consuming space. Always-On hands back the hidden navigation bar's height, so the container grew by that much and the `Spacer(minLength: 6)` between the phase block and Top Heard absorbed the slack. The top-anchored phase block landed correctly and Top Heard was pushed down — which is exactly what Adam saw: headers matching, Top Heard low. Applying the same value as `.padding(.top,)` consumes the returned height as well as setting the origin, so the content box matches the full-luminance reference in size and position. The spacer then has the same slack it has at full luminance, which is none, and Top Heard packs where it does there. Padding a descendant cannot disturb the measurement: the reader takes its safe-area inset from the navigation host, which our padding does not feed. That is also what still permits the top reference to track a maximum while the bottom latches its first nonzero value. Full-luminance readout measured unmoved to 0.00 pt on both sizes again.
Adam asked for a much tighter starting zoom. The default drops from 0.0045 to 0.00225 degrees latitude — about 250 m north-south — with a one-time `map.zoomDefaultsVersion` migration, because the wearer's zoom is remembered and every watch that has shown the map already has a stored value that would otherwise win. The constant alone would not have worked. `noteRenderedRegion` guarded only on `programmaticCenter != nil`, which proves a region was requested, not that MapKit rendered it. MapKit reports its `.automatic` annotation fit for several callbacks after our first assignment, and those spans were persisted as though the wearer had chosen them. A fresh install stored 0.136 degrees on 40 mm and 0.0875 on 46 mm — 15 km and 9.7 km — and read that back on the next launch, so the map started wide and stayed there. That is almost certainly the behaviour being reported, and it would have eaten the new default within seconds. The centre had already been given this treatment: nothing counts as a pan until MapKit confirms a centre we asked for. The span now gets the same handshake. Traced on a fresh install: three automatic callbacks at 0.136 rejected at 59x the request, then a rendered 0.00225 confirms, after which callbacks reach the normal write-back and decline only because the value is unchanged. The confirmation tolerance is deliberately loose at 25%. It separates our request from a fit dozens of times wider, and an exact test could wait forever if MapKit adjusted a span for display geometry, silently disabling zoom memory — a quieter failure than the one being fixed. Fresh install and warm relaunch both hold 0.00225 on 40 mm and 46 mm.
The top bar holds exactly one item per side — a second leading item is silently dropped, which I confirmed by rendering it — so the trailing slot is the only one left, and the system moves the clock to centre to make room. It now carries session control. Which control owns the slot follows stable facts, never the live ping gate: no session -> Start running, ping applicable + opted in -> Ping running, otherwise -> Stop That distinction is the point. `canManualPing` flickers with cooldowns, RX windows and discovery windows, so keying the slot on it would change the button's identity under a moving thumb and turn a ping into a stop. The phone now publishes `manualPingApplicable` — connected, TX permitted by the region, and no Active, Hybrid or Trace session running — which holds still for the duration of a session. Live availability only enables or disables, so a cooldown greys the ping button rather than replacing it. Ping cannot fire during Active or Hybrid at all; `isTxModeRunning` gates it in the shared availability rule and again in the phone's own button. So the slot correctly shows Stop there rather than a permanently dead ping. Stop and Ping take two taps within three seconds, reusing the arming pattern manual ping already had, because this button sits under the thumb on a surface that is also panned. Start does not confirm; starting is recoverable and stopping loses the session. Start now sends an explicit mode. The snapshot advertises which the zone permits, the wrist picks its default from those, and the phone revalidates and refuses rather than downgrading. Settings gains Default start mode, defaulting to Passive with Hybrid offered only where it is permitted, and states plainly when a stored Hybrid cannot be honoured. Main page and Node list move above Map, so the choice that decides whether the map toggles matter is no longer below them. All six slot states driven through sample data on both watch sizes. `effectiveStartMode` is currently computed in two places; consolidating it is the first item of the next change.
It used raw system tints and a bare stack of two buttons, which is most of why it read as a page from a different app. It now uses the phone's own vocabulary: slate surfaces, green #22C55E, the app's red #BD2130, indigo #6366F1, a 12 pt material card with a hairline border matching the map's status panel, and the readout's approved type scale. Three substantive changes beyond paint. A compact mode and phase header, because the page previously gave no way to see what was running while looking at the controls for it. `blockedReason` and refusals now sit directly beneath the control they explain rather than floating at the bottom as two grey strings. And the phase shrinks to fit rather than truncating — on 40 mm it rendered "Listenin…", and since the titles the phone sends already end in an ellipsis, that read as a rendering fault. The controls were 69.5 pt tall against a 44 pt ergonomic floor, which pushed the last one to within 5 pt of the 46 mm display bottom. At the card's 14 pt margin and that watch's ~37 pt corner radius the curve needs about 8 pt, so its corners were clipped — the failure mode this project has hit repeatedly, and again invisible in a flat simulator rectangle until measured. Moving the 44 pt guarantee to the styled button rather than its label brings them to 52 pt on 46 mm and 44.5 pt on 40 mm. Every sample state now fits both displays without scrolling, with 40 pt and 16 pt of clearance in the tightest one. `effectiveStartMode` is now single-sourced on `WatchSettings`; the map and settings copies had already begun to diverge in form. The toolbar's trailing control drops its capsule fill for a coloured glyph on the default glass, matching the leading toggle it sits opposite. Only the armed state stays filled: the confirm window is the one state with a consequence attached, so it is the one that should be loud. One defect fixed along the way. Associating refusals with their originating control had routed phone-originated cues through the same path, so an unrelated cue inherited whatever the wrist last tapped and could report a failure under Manual ping for a ping that succeeded. `WatchHapticCue` carries no correlation to a command, so attribution is now explicit per call site and cues are deliberately unattributed.
Three fixes from Adam's walk. The trailing control looked empty with nothing connected. It was drawn correctly — a disabled play glyph in slate on glass — but slate over Apple's darkest basemap is invisible, so it read as an empty circle. Disconnection now has its own glyph rather than a dimmed Start, and the disabled colour moves to the brighter slate so that no disabled control reads as empty. The accessibility label still distinguishes a missing phone from a missing device even though the glyph cannot. Satellite did nothing. `.mapStyle(.imagery)` renders pixel-identically to `.standard` on watchOS: a diff of the two states across the whole basemap band found zero differing pixels, and it behaved the same on Adam's watch. The toggle is gone rather than left as a control that silently does nothing, and the header comment records it alongside the other watchOS MapKit limits. The map claimed both gestures. `interactionModes` included `.pan`, so vertical swipes dragged the basemap instead of paging and the status panel was the only place a swipe could change pages. Zoom-only gives the page back its swipe while leaving the crown as the deliberate zoom control. That removes the pan-suspension machinery entirely, and deliberately rather than incidentally. Its purpose was to stop fighting a wearer who had dragged the map. With drag gone, the only remaining source of centre drift is MapKit settling and crown zoom — neither of which is a wearer moving the map — so a distance heuristic could no longer identify a pan truthfully, only misfire and suspend follow for eight seconds. Keeping it would have introduced the bug this change was meant to avoid. `programmaticCenter` stays: it is now solely the first half of the span handshake that keeps `.automatic` from becoming the remembered zoom. Fresh installs still settle at 250 m on both watch sizes.
Pre-beta review round one: four correctness defects, none cosmetic. The wrist could start a session the phone's own button would have refused. `canStartStop` checked connection and nothing else, while the phone gates Start on nine conditions. Starting Active from the wrist while a manual ping was in flight made `sendTxPing` return early on `_pingInProgress`, so the first automatic ping neither transmitted nor scheduled the next cycle: a session reporting itself active that never advanced. A tester could have walked an hour and come back with nothing. Start admission is now one rule, mirroring how `_manualPingAvailability` is already the single copy of the ping gate, and consumed by both the offered button and the radio admission. It is mode-aware on purpose: Passive does not transmit, so TX cooldowns, receive windows and zone TX policy must not block it — refusing a Passive start because a manual ping was in flight would be the same bug pointing the other way. `ping_controls.dart` keeps its own copy for now; its enablement is entangled with running-mode toggles and labels, and restructuring the phone UI days before a beta buys a worse risk than it removes. `DebugPage` shipped in Release with a single-tap manual ping, bypassing the confirmation the real surfaces have precisely because that action transmits. It is now DEBUG-only, and while it remains there its ping confirms and its Start sends the configured mode. Command failures had no presentation on the map. The toolbar is now the primary way to start and stop a session, but refusals only reached `ControlsPage`, so a wrist Start could fail, clear its spinner, and explain nothing on the surface being looked at. Both surfaces now show a transient toast on the existing six-second lifetime, layout-neutral and absent at reduced luminance. The readout anchors it to the safe-area child rather than to the black backing: that sibling ignores the safe area and would have placed a bottom-aligned overlay off-screen, the third time this file has been bitten by geometry read from an expanded region. Sized to clear the corner radius so it drops to 2.5 pt off the physical edge, it sits below Top Heard entirely on 46 mm. Controls sent no mode while the toolbar sent an explicit one, so the two made different promises — and on a fresh phone session in a TX region, ambient `_autoMode` can be Active while the wrist's stored default is Passive. Both now send the same single-sourced effective mode. One known limit, not worth wire churn before the beta: enablement is computed for Passive because the chosen mode is watch-local, so a blocked Hybrid start shows an enabled button and an explained refusal rather than a disabled one. Publishing availability per mode would fix it properly. `-MeshMapperForceRefusal` joins the DEBUG launch arguments, since the banner is otherwise unreachable in a simulator with no way to tap.
Measured on a maximal payload — 60 pings, 20 repeaters, four heard rows, encoded exactly as the wire does: full 11,609 bytes suppressed 1,443 bytes (87.6% smaller) Geography was built unconditionally while the phone had no idea what the watch was displaying; it knew only reachability and activation. So every ping coordinate, repeater name and colour was sorted, encoded, transmitted, decoded and discarded whenever the wearer sat on the readout, on Controls or Settings, or — most of a walk — had the wrist down in Always-On, where the readout draws none of it. The wrist now reports whether its current surface needs geography, over the queued command path that already existed. The phone skips building the markers as well as encoding them, so the wasted sort of up to two thousand ping candidates down to sixty goes with them. Every uncertainty resolves toward sending geography, because the failure modes are not symmetric: extra bytes cost battery, an empty map costs the feature. Suppression is a lease the wrist renews rather than a latch it sets, so a watch that stops reporting returns to full payloads by itself. Launch assumes geography is needed until the surface says otherwise. Returning to the map requests a full snapshot immediately instead of waiting for the next scheduled push, and a suppressed payload arriving after the map became visible triggers a throttled replacement rather than leaving half-stale markers on screen. Suppression waits fifteen seconds so an ordinary glance does not enqueue a false-then-true pair, which would trade one kind of waste for another. Wire stays at version 2: both fields are additive, an old phone ignores the claim and keeps sending everything, an absent flag decodes as included.
The Crown zooms about the map's region centre. We had been lifting that centre by hand so the fix rendered above the status panel, so a zoom slid the puck across the screen and the correction pass snapped it back when the gesture ended. Both halves worked as designed; the design was the problem. Telling MapKit what is covering it — a safe-area inset for the panel and for the toolbar strip — makes its own centre the centre of the band the wearer can actually see. The fix then lands there because it is centred on, not translated toward, and a zoom anchors on it. Adam drove the Crown on a build of this and reported it stays centred throughout, which is the half neither the simulator nor a static capture can show. That removes the machinery the old approach needed: the target point, the coordinate translation, the post-render correction loop and its deadband, and the remembered rendered centre. `programmaticCenter` stays, now solely as the sentinel for the span handshake, which is unaffected — it is only ever tested for non-nil. Both edges are inset on purpose. Insetting only the bottom centres the fix in the band from the top of the display to the panel, which still includes the toolbar, and the puck reads high — plainly so on 46 mm where the chrome is a smaller fraction of the screen. Measured: the fix sits at 91.2 pt on 40 mm against a predicted 92, and 116.8 pt on 46 mm against 117. The status panel is pixel-identical across every variant, a fresh install still settles at 250 m on both sizes, and a panel height change — forced with long hex IDs — leaves the persisted span untouched, so a growing panel cannot drift the zoom. `MapReader` is now vestigial: nothing calls `proxy.convert`, so the proxy is threaded through five functions unused and its comment is no longer true. Removing it is a hierarchy change around a map whose launch and framing behaviour was just verified, so it belongs in its own change with its own A/B rather than riding along here.
Camera placement moved to safe-area insets, so nothing calls proxy.convert any more and the reader's comment claimed a purpose it no longer has. Record what it is and why removing it wants its own change, rather than leaving a false rationale for the next reader to trust.
Marker ids embedded the entry's index in its source list. Those histories insert at the front and trim from the back, so one new discovery renumbered every surviving marker and SwiftUI saw sixty replacements rather than a single insertion — tearing down and rebuilding the whole annotation set to show one new dot. TX and RX had the same fault once their five-hundred-entry lists began trimming. Identity now comes from the event's own timestamp, which is intrinsic to it and survives both insertion and trimming. Two events of one kind inside the same millisecond are the only case needing a discriminator, and it counts within the colliding group rather than across the list, so it does not reintroduce positional churn. The ids are also marginally shorter than the ones they replace. The regression test was checked against the old implementation and fails on it, so it covers the property rather than merely describing it. Not measured: how much energy the churn actually cost. That needs Watch Instruments on hardware. The churn itself was certain from the code, and the fix is cheap enough not to need the number first.
The bar's fill is an animated layout width, so SwiftUI re-runs layout on the main thread every frame for the whole phase instead of handing a transform to the render server. That is the expensive class of animation, and a session is almost entirely consecutive countdown phases — but how much energy it actually costs is not knowable from the source, and the cheaper alternatives change how the bar looks. Adam chose this bar, so the measurement has to come before the redesign. A DEBUG-only Settings toggle freezes the drain and leaves everything else running: same snapshots, same markers, same live timer text, same layout. An Instruments trace of the two states therefore isolates this one animation rather than a whole different build. It lives on the watch rather than behind a launch argument so the two states can be compared on a walk without a Mac, and it is read statically so flipping it does not invalidate the view mid-drain and perturb the trace it exists to produce. Compiled out of Release.
Swiping up from the map left the controls page blurred and deaf to every swipe and Crown turn. It reads as a crash and is not one: the process lives, holding a modal it should never have raised. Two faults compound. The status panel is a button that opens the heard sheet, and it lies across the bottom of the map — exactly where an upward page swipe begins — so the pager and the button both claimed the same touch. Then the page changed underneath the sheet, leaving a modal belonging to the map presented over the controls page, blurring it and swallowing input. That became reachable when the map gave up `.pan`. Before, an upward drag moved the basemap; now it pages, so wearers swipe from wherever their thumb rests, and the panel is the largest target on the screen. The panel now opens the sheet only when the heard list is actually placed in a sheet. With the list on its own page — the default — it was presenting a duplicate of a page that already existed, so the tap had nothing to offer and every cost of firing by accident. And a sheet is dismissed when its page stops being selected, because a sheet belongs to the page that raised it; that guard holds however the sheet was raised. Reproduced first: presenting the sheet and then changing pages leaves it stranded with the controls page's buttons bleeding through behind it. Both are fixed, and the sheet still presents correctly over the map when that is the chosen placement. The page-switch launch argument used to reproduce it stays, documented with the others. The simulator cannot swipe, and this class of defect is invisible without it.
Swiping off the map wedged the watch: the destination page rendered blurred and stopped accepting swipes and Crown input. No crash report, process alive — the main thread simply never came free. A log capture during the gesture showed why. In forty-five seconds: 444,859 lines, 21,611 MapKit reconfigurations, and 194,490 evaluations of one debug flag read from the view body. Roughly 480 map rebuilds and 4,300 body passes per second. The cycle ran through the camera inset added in c2b9735: panel measured in .global -> panelFrame -> panelCameraInset -> Map safeAreaPadding -> layout invalidation -> new global frame An interactive swipe translates the page, so the panel's global position changed every frame, cleared the half-point guard, and re-framed the map again. Assigning `selection` in code swaps pages without ever producing those intermediate positions, which is exactly why every test passed while the real gesture was broken. Three rounds of green simulator runs described a build that locked up in ordinary use. The inset only ever needed to know how much of the display the panel covers, and that follows from its height plus the gap beneath it — both invariant under translation. So the cycle cannot close: moving the page no longer changes anything the map is told. The inset is also clamped to three quarters of the display. A status panel has no business claiming more, and a future measurement fault should degrade the framing rather than starve MapKit of anywhere to put the camera. Placement is unchanged: the fix renders at 91.25 pt against 91.2 pt before. Adam confirmed by hand that the gesture no longer wedges, and the same capture now yields 10,146 lines with 704 map reconfigurations.
`camera` started `.automatic` and `recenterIfFollowing` was the only thing
that ever assigned a region, so its three early returns each left the camera
untouched. `.automatic` fits every annotation, which on hardware meant 34.4
degrees of latitude — about 3,800 km — and nothing recovered from it. Twenty
consecutive continental callbacks across five wrist raises; only a manual
Crown zoom escaped.
Both triggers are now confirmed on a Series 9 and both are handled:
Follow off reproduced in the simulator, which had never shown this
because nobody had turned Follow off there
fix == nil caught on device, `.automatic` reporting a 58.4-degree fit
`anchorCameraIfNeeded` asserts a region once per native-map lifetime from a
fresh stored centre, else the live fix, else a stale stored centre, so
`isFollowing` governs tracking rather than whether any region is asserted at
all. The centre persists in `WatchSettings` as one array — two keys can tear
if the app is killed between writes — with a timestamp, because this map
cannot pan and opening on a centre the wearer has travelled away from strands
them somewhere they can only zoom.
The span handshake is unchanged and still load-bearing: the `.automatic` fit
arrives first on every rebuild and is rejected by the 25% gate, so a broken
camera never corrupts the saved zoom. Verified in the logs as
`confirmed N drift 3789.0%` followed by our own region at `drift 0.0%`.
Also here, all measured rather than reasoned:
- Past roughly 22 degrees of span MapKit shifts the camera centre north and
leaves it there — 8.2 km at 24.5 degrees, 14.0 km at 25.0 — and the shift
survived every later zoom including one back in to street level. It is now
repaired after each Crown zoom, preserving the rendered span so correcting
the centre does not undo the zoom.
- The zoom floor drops to 0.0002. The Crown reaches 0.000230 degrees (~26 m)
and the old 0.0005 floor clamped it, so zooming in snapped back out. The
ceiling stays 0.5: zooming out past it still works, only persistence caps.
- `MapReader` is gone. Camera placement moved to `.safeAreaPadding`, nothing
called `proxy.convert`, and the proxy was threaded through six functions
unread.
- `WakeLog` and a persisted Instruments toggle, because launch arguments live
in `NSArgumentDomain` and evaporate when watchOS relaunches the app, which
is exactly what a wrist-down test provokes.
- No main-actor hitch detector. One was written and removed: it fired on every
wake at almost exactly the wrist-down duration, because it was timing the
app being suspended rather than the main actor being blocked.
The phone stopped sending snapshots for two hours and said nothing. The cause was `WCSession.isWatchAppInstalled` reporting false, so `WatchSessionManager.send` returned false and Dart's `schedule()` bailed before attempting anything. Neither side logs that: the bridge had no logging at all, and native only logs when a real send attempt fails, which never happened. `statusDictionary()` held the exact answer, handed it to Dart, and Dart showed it nowhere. Diagnosis took an hour and three wrong theories — a WCSession pairing fault, a second app install competing for the radio, two sources of truth for `isConnected` — none of which survived comparing file timestamps in the app containers. This is the surface that makes that a one-minute lookup instead. Settings gains an Apple Watch section listing the five native WCSession values, Dart's derived `canSync`, and — the point of the whole thing — which of `activated`, `paired` and `installed` is failing when it is false. Plus time since the last successful send, time since the last availability change, and whether that send was delivered or refused. Reachability is shown but deliberately excluded from the gate, because it is not one of the three conditions and mistaking it for one is exactly the wrong turn taken during the incident. The entry appears only when a watch is paired now or has ever been paired. The history flag is one-way on purpose: an unpaired watch is precisely when this needs to stay reachable, so no later native false may erase it. Also here: `statusDictionary()` now returns `activated` from its no-session branch too, so Dart never reads a missing key; and `_applyAvailability` logs the full status map on change only, never per send — the payload design exists to avoid chatty updates and this must not undo it. Tests cover both directions: the healthy gate, and the incident-shaped state where `installed` is false and the screen must name it.
Pings carry their own transmit-time GPS while the puck was held still until the fix moved fifteen metres, so on a walk the pings led the wearer in the direction of travel. Tightening the watch's zoom floor to about twenty-two metres of visible latitude turned a long-standing offset into two thirds of the display. The fifteen-metre gate exists to stop a parked phone's GPS jitter waking the radio, and it did that by making the payload identical so the bridge deduped it. But a new ping changes the payload by itself: that packet goes out regardless, and sending a stale puck inside it buys nothing. So ask the question in the transport instead of answering it in the payload. WatchBridgeService now encodes the fingerprint a second time with geo.you removed and skips the send only when everything else is byte-identical and the fix has moved less than the threshold. The anchor is the fix the watch actually received, so two eleven-metre steps add up and go, and a refused send cannot silently consume the wearer's next fifteen metres. Position-derived fields keep the old gate. distanceM is a full-precision double and the repeater list is distance-sorted, so feeding those the live fix would move the payload on every jitter in fields the transport cannot recognise as position — a send every two seconds from a phone on a table. _resolveRankingPosition holds them still; fifteen metres is invisible in a kilometre-scale readout and was only ever visible in the puck. movedEnough and distanceMeters move to WatchWire beside minMoveMeters, so the transport can apply the gate without importing a builder that reaches back into AppStateProvider.
Adam, on a walk: "the watch often falls back to the always on state very rapidly despite still being in the lifted position. This can make the map flicker on and disappear while a user is looking at it." It is not the wrist coming down. watchOS drops to reduced luminance about 5.9 seconds into a glance — thirty-three lifts measured, fourteen inside a half-second band — and the clock face does the same, so this is the platform's full-brightness window and there is nothing upstream to fix. The display stays legible. showsMap read the dim as nobody looking and tore the whole MapKit subtree down mid-glance. The map now survives the dim for twenty seconds. The geo claim does not: needsMapGeo reads luminance directly instead of going through showsMap, so the held map draws the pings the watch already has and the phone sends no more than before. MapKit staying constructed is a real cost, and twenty seconds a glance is the trade; a wrist-down's worth would not be. Four things had to hold at once, and hardware taught three of them. The hold cannot depend on a timer. watchOS runs the app at a much lower cadence once dimmed, so a boolean owned by a sleeping task would stay set for an entire wrist-down and leave a map as the Always-On surface. The predicate reads the clock. A clock is not enough either, because the last frame drawn stays on the display until something asks for another. A repeating TimelineView requests that render. Its entries are not the deadline, though: entering Always On changes the timeline cadence, which re-queries the schedule and delivers the next entry early. Treating that as the boundary expired the hold 0.82 to 1.05 seconds into every glance, thirteen for thirteen on a Series 9, while the simulator — which never enters Always On — passed every time. The clock decides; the entry only says when to look. The first dimmed frame must already be holding, because the timestamp is written by onChange, which runs after the body evaluation that first sees the dim. So a missing timestamp means hold. That default is fenced by hasBeenBright, which no view-lifecycle callback may clear: watchOS re-hosts this page at the dim, and clearing it there put the wearer back on the readout a second in. Fresh @State already covers what the reset was for. And nothing may change shape at the dim. The toolbar items were wrapped in an if, and removing them alters the toolbar's structure, which re-hosts the page and rebuilds all of MapKit beneath it — a subtree appearing 0.13 seconds after every dim, with a fresh camera anchor and basemap repaint, which the wearer saw as the map jumping as it faded. They now stay put and are hidden by value. Disabled and accessibility-hidden as well as transparent, because Start transmits on a single tap and an invisible control is still reachable by an assistive technology. -MeshMapperAutoDimAfter drives the transition in the simulator, which MeshMapperForceDimmed could not: it only sets the state a page is born into, so it exercised the cold-dimmed path and never the glance. Every fix above was found by measurement rather than reasoning, three of them only after hardware disagreed with a simulator that had signed the work off. Also records the settled cause of the double map construction. It is not the map: a bare probe above the branch doubles, and so does one in the readout on a launch with no MapKit anywhere, while probes on the NavigationStack and the TabView each fire once. The vertical pager builds its selected page twice and discards one, and nothing here can prevent it.
They slept a fixed 2.5 seconds against the bridge's 2 second non-urgent throttle and the retry it schedules, which left about half a second of margin. That was enough until a loaded machine ate it, and a suite that fails one run in ten teaches people to re-run rather than to look. Only the assertions that nothing was sent actually need to wait, so they keep a fixed window comfortably past the throttle: "no send" has to mean suppressed rather than merely deferred. The ones expecting a send now poll for it instead, which is both faster and indifferent to how busy the machine is.
The three watch configurations carried a 26.0 deployment target, which gates installation on every earlier OS. Nothing in the app needs it: the compile floor is 10.0, and below that only `@Observable` and one `NodeListView` initialiser fail. watchOS 11 and 26 share a hardware floor of Series 6, so this costs no device support at all and reaches everyone who has not updated. Built, installed, and launched on watchOS 10.5, 11.5, and 26.5. Map, controls, and settings all render, including on the smallest watchOS 10 screen, because layout is measured from safe-area insets rather than tuned to one version's chrome. Also drop the Foundation.framework reference, which pointed into a WatchOS11.0.sdk path that no longer exists in Xcode 26. Swift links Foundation implicitly, so the entry was inert generator residue.
A queued command carries the time the wrist tapped it, and the phone refuses one older than 30 seconds so a transmit cannot be attributed to a place the vehicle has already left. Only the upper bound was checked, so a timestamp in the future made the age negative and sailed through: a watch clock running ten minutes fast bought its commands ten extra minutes of life. The map-geography check directly above already had this right, and now both use the same tolerance. The window had no coverage at all, in either direction. Add it for every transmitting command: too old refuses and never reaches admission, a small forward skew still runs, and a far-future stamp refuses. Also pin the two exemptions, since both are deliberate — an untimestamped command from an older watch build is still accepted, and requestSnapshot is exempt because it transmits nothing.
Staleness was measured from when a snapshot reached the wrist. On launch the app ingests whatever application context WatchConnectivity retained, which can be hours old, and stamping arrival there presented long-dead state as current for a full 90 seconds — dimming and the age badge both said the phone was in touch when it was not. Age from the phone's own updatedAt instead, which the payload already carried and nothing read. Clamped to arrival so a phone clock running fast cannot date a snapshot into the future and extend its life, with a few seconds of slack so ordinary skew does not age a live payload early. A context already past the boundary is now stale immediately. The cue freshness check wanted the same tolerance for the same reason, so the two constants become one.
The app header still described Phase 2 as shipping a transport and a raw debug dump with the map, node list, and controls due later. All of them are here, so a reader had no way to tell which files were finished. The satellite preference was write-only: declared, persisted, loaded on launch, and read by nothing. Settings offers no toggle for it, and the map page documents why there is none — Apple's .imagery renders indistinguishably from .standard at this size.
The suite is no longer empty: 196 tests cover the watch wire contract, geo suppression, redelivery dedupe, movement gating, and command admission, and they are what protects this work from a quiet regression. The comment claiming there were no tests had outlived its truth.
Ageing retained state from the phone's updatedAt fixed state that lied about being fresh, and exposed the reciprocal hole: the refresh the watch sends to escape that state could be deduplicated into silence. The phone forgets its delivered-payload fingerprint only when WatchConnectivity reports a state change. A watch app relaunch is not one — `sessionReachabilityDidChange` is not implemented, and pairing and installation are unchanged, so nothing calls `publishStatus()`. The fingerprint survives, the rebuilt payload matches it once updatedAtMs is stripped, and `_flush` returns without sending. The watch then sits on a context it has correctly marked stale, with no way to prove otherwise, until something unrelated moves. It needs the map to have been visible when the watch died: a suppressed map means requestSnapshot restores geography, which changes the payload and defeats dedupe by itself. Leaving the watch on the map page is both the common case and the one where the dimming is most visible. So requestSnapshot now forces delivery. Only that command does — most immediate updates are immediate precisely because something changed, and should stay deduplicatable. The radio throttle still applies, and the obligation outlives its own deferral rather than bypassing it, so a watch asking repeatedly cannot turn this into an unmetered path to the radio. It is cleared on delivery, not on the decision to send, so a payload native refuses does not strand the wearer exactly as before.
requestSnapshot carries two intents down one wire. One is a genuine plea for state, after a relaunch or a resume onto a retained context of unknown age. The other only changes what future snapshots contain: the map-geo lease, which renews as the same command every five minutes for as long as the map stays hidden. Forcing delivery for the command kind therefore forced an otherwise identical snapshot on every renewal — spending the radio exactly where the lease exists to save it, and against the comment saying renewal "keeps a long Always-On session cheap". Carry the intent explicitly instead. It is not inferred from mapGeoNeeded, which the bridge may legitimately resolve to nil when a suppression claim arrives stale or out of order, and which is also true for an ordinary return to the map — a transition that already defeats dedupe by restoring geography and needs no help. Absent on the wire means false, so a phone paired with an older watch build behaves as it did before. Also pin the behaviour the force path leans on hardest: a send native refuses leaves the fingerprint untouched, so the obligation must survive it or the next flush dedupes against that same payload and strands the wearer exactly as before the fix.
`onAppear` fires once and cannot speak for a resume, and watchOS suspends this app for essentially the whole wrist-down interval — measured here at 7.42 s of suspension against 8.20 s of wrist-down, 12.89 against 13.63. Nothing else asked the phone for anything. So the dominant interaction on this device, raising a wrist, could land on state the UI itself declares stale while the phone was available and willing the entire time. Observe scenePhase and reconcile when the scene becomes active. This is deliberately not `refresh()`, which always requests and would put a WatchConnectivity round trip behind every glance — the opposite of what this transport is for. Ingesting the retained application context is free, so it happens on every resume; the radio is spent only when what we hold is stale or missing, which is exactly when a request can change what the wearer sees. A glance onto fresh state costs nothing, and the worst case becomes roughly one request per stale interval while someone is actually looking at the watch, rather than one per raise. The decision is logged to the existing wake-timing harness, because it is otherwise invisible: a run of wrist raises should show `resume-local` while the state is fresh and exactly one `resume-request` on the first glance past the stale boundary. That is the claim, and on hardware it is the only way to see whether it holds.
The watch bar animated frame(width:) from its current fraction to zero for the length of a whole phase, so SwiftUI re-ran layout for the panel on every frame of a 60-second drain instead of handing one transform to the render server. It is now a leading-anchored scale, and it stops entirely under reduced luminance — Apple's Always-On guidance, and newly load-bearing since the dim hold keeps this panel on screen for up to 20 s past a dim, which is exactly when it was animating over a screen that updates once a minute. The scale applies to a Rectangle mask rather than to the capsule. Scaling the capsule squashed its end caps, so the bar started rounded and finished square. A comment here previously excused that as "the shape it was already collapsing to anyway"; Adam saw it within a minute of getting the build on his wrist. The Live Activity bar was asking ActivityKit for something it does not do: a @State fraction driven by a withAnimation lasting the entire phase. Widgets cap a custom animation at two seconds and run none at all under reduced luminance, so on an always-on lock screen the fill stayed where the last update left it — "mostly full at 9 seconds remaining". Every @State, Task and withAnimation is gone. The title and native countdown share a row and a ProgressView(timerInterval:) draws beneath, so both are derived from absolute dates and every render the system chooses to make lands correctly. The bar now fills rather than drains, which is the trade for handing the work over: its progress comes from a date range and cannot be inverted. Its range is the whole phase, never now...deadline, which would reset it on every redraw. Also here, and confirmed on hardware after one failed attempt: the drain no longer outlives the phase. Assigning a value it already holds does not end the animation driving it — withAnimation sets the model value immediately and animates only the presentation, so mid-drain remainingFraction is already 0, and the snap-to-truth on a stopped session assigned 0 to 0 and changed nothing. The bar kept draining to the old deadline while the title read "Ready". A dim never showed this because a dim mid-phase has a non-zero truth. The nudge that forces a real change needs its own transaction, because SwiftUI batches state changes made in one run-loop turn and the first attempt coalesced into a single net-zero change. Non-urgent Live Activity updates now wait 15 s rather than 2. Phase changes, ping outcomes, connection loss and zone changes stay in urgencyKey and still go immediately; counter and repeater churn waits. This saves on both devices, since a locally generated ActivityKit update is mirrored to the paired watch and counts against its Live Activity budget. The bar probe logs phase title, deadline, duration, fraction, lapsed and whether the fill drains, on every re-run of the phase task. It is what separated "the task never re-ran" from "the animation outlived the state", after this one line had already been wrong three different ways in a day.
Adam reported the map jumping at the end of the wait and listening timers. A DEBUG probe, gated to a 2.5 s window after a phase change so a walk costs a handful of lines rather than hundreds, named the cause in one session: the panel's height. panelCameraInset feeds .safeAreaPadding(.bottom,), so every height the panel passes through was a camera reassignment. Measured across one session stop and restart, 54 -> 59 -> 40 -> 54 inside three seconds, four applyRegion calls, none animated, while the fix itself moved 0.2 m. Ordinary phase boundaries did not move the panel at all — it held at 54.0 through four of them — so this is a session-lifecycle event rather than a per-cycle one. Zoom decides whether it is visible: at the 40 m span Adam happened to be holding, a 19 pt inset change moves the framing centre 1.6 m and cannot be seen, which is why an hour of stationary watching found nothing. At the 250 m default zoom the same swing is roughly 9 m. An inset change is also an apparent zoom, not merely a pan. applyRegion holds the span constant while the safe-area padding changes the height of the band MapKit fits that span into, and fewer points for the same span is a larger scale. Nothing had said so before, and it is what made two rejected attempts read badly: settling with growth adopted immediately gave a zoom in followed by a zoom out 450 ms later, and settling symmetrically with an eased reframe still showed one step each time the panel populated or emptied. So the camera frames against the panel's high-water mark, which was Adam's suggestion: treat the panel as though it were always at its maximum. A shrink then moves the camera not at all, which is the common case and the one he saw. Growth still waits 450 ms for the height to stop moving and then eases, so a transition costs at most one gentle adjustment. Confirmed on the wrist: "no movement on start or stop." The trade is that while the panel is shorter than its maximum the map is framed as if it were not, so the puck sits above the true centre of the visible band by half the height difference, under about 10 pt at the sizes measured. A puck slightly off centre that never moves beats a centred one that jumps. The mark is per-launch @State: it survives the map subtree's teardown on a wrist raise, so it holds across glances, while a fresh launch re-derives it rather than inheriting a measurement from a content shape that has since changed. The 0.75-of-display clamp still bounds it. The settle is deliberately fail-safe rather than timer-dependent, since watchOS suspends this app and the sleep fires whenever it is next resumed — the trap the dim hold was bitten by twice. A late fire only reframes late, and the map subtree's onAppear adopts the measured height outright, so no wrist raise can find the camera framing against a height the panel has abandoned. The reframe itself is untouched. It is the only thing keeping the camera on the visible band, and this file has two scars from deleting an apparently redundant camera assignment; only which height it follows has changed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR adds two Apple companion surfaces to MeshMapper:
Both surfaces remain projections of the iPhone-owned session state. The phone continues to own the MeshCore connection, GPS fix, session lifecycle, transmit policy, and command admission; the Watch and Live Activity surfaces render that state and send user intent back to the phone.
These features are included together because they share the same underlying session-state projection and lifecycle work rather than maintaining independent copies of application state.
Apple Watch companion
The watch app includes:
The watch does not independently drive a MeshCore session. Commands from the wrist are treated as intent and are revalidated by the phone before anything can transmit.
WatchConnectivity transport
The Watch bridge was designed to keep radio and processing overhead low during wardriving:
updatedAtmetadata does not itself defeat deduplicationExplicit refreshes are distinguished from map-demand updates. A genuine request for current state can defeat payload deduplication, while map-geo lease renewals remain deduplicatable.
Watch lifecycle / stale-state handling
WatchConnectivity may retain application context across watch app launches, so retained state is aged from the phone's original
updatedAttimestamp rather than from the moment the watch process reads it.The watch also reconciles state when its scene becomes active again. watchOS suspends the app while the wrist is down, so
onAppearalone is insufficient for normal wrist-raise behavior.On resume the watch:
This avoids both presenting old retained state as current and placing a WatchConnectivity round trip behind every wrist raise.
Live Activities
The iOS app now exposes session progress through ActivityKit, including current mode/phase, countdown state, recent repeater information, ping status, and session counters.
The implementation is local to the app and does not require a push server or separate App Group state.
Compatibility
Testing
The branch adds coverage for:
CI now runs: