diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 08f6a81..5d3f3bd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,5 +23,4 @@ jobs:
- run: dart run build_runner build --delete-conflicting-outputs
- run: dart format --output=none --set-exit-if-changed .
- run: flutter analyze
- #- run: flutter test
- # no tests yet, fails without ./test directory
+ - run: flutter test
diff --git a/docs/LIVE_ACTIVITIES.md b/docs/LIVE_ACTIVITIES.md
new file mode 100644
index 0000000..f0f6075
--- /dev/null
+++ b/docs/LIVE_ACTIVITIES.md
@@ -0,0 +1,29 @@
+# iOS Live Activities
+
+MeshMapper starts one read-only Live Activity while an automatic wardriving session or manual ping cycle is active. It mirrors the same state used by the in-app controls.
+
+## Displayed information
+
+- Current mode and phase (`Sending`, `Listening`, `Next ping`, `Cooldown`, GPS/zone/reconnect states)
+- System-rendered countdowns based on absolute timer deadlines
+- The strongest repeaters from the current or latest completed cycle, sorted by SNR
+- TX/RX counters, upload queue, zone, connection state, and stale-update warnings
+- Lock Screen, Dynamic Island, and an iOS 18 small-family layout suitable for CarPlay on supported systems
+
+## Architecture
+
+- Dart builds a compact `LiveActivitySnapshot` from `AppStateProvider`.
+- `LiveActivityService` deduplicates and throttles noncritical updates before sending them over `meshmapper/live_activity`.
+- `LiveActivityManager` creates, updates, deduplicates, and ends the native ActivityKit activity.
+- `MeshMapperLiveActivityExtension` renders the snapshot using SwiftUI and WidgetKit.
+
+No push server or App Group is required. The Live Activity does not replace the existing BLE/location background execution; it only presents its state.
+
+## Platform requirements
+
+- Live Activity: iOS 16.2 or later
+- Small supplemental activity family: iOS 18 or later
+- CarPlay presentation of Live Activities: iOS 26 or later
+- A physical iPhone is recommended for final background, Dynamic Island, and CarPlay validation
+
+The main Runner target keeps its existing deployment target. Unsupported devices skip ActivityKit creation.
diff --git a/ios/Flutter/LiveActivity.xcconfig b/ios/Flutter/LiveActivity.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/LiveActivity.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/MeshMapperLiveActivity/Info.plist b/ios/MeshMapperLiveActivity/Info.plist
new file mode 100644
index 0000000..9ff3b6b
--- /dev/null
+++ b/ios/MeshMapperLiveActivity/Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDisplayName
+ MeshMapper
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ NSSupportsLiveActivities
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift
new file mode 100644
index 0000000..14f3731
--- /dev/null
+++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift
@@ -0,0 +1,827 @@
+import ActivityKit
+import SwiftUI
+import WidgetKit
+
+struct MeshMapperLiveActivity: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: MeshMapperActivityAttributes.self) { context in
+ Group {
+ if #available(iOSApplicationExtension 18.0, *) {
+ MeshMapperResponsiveActivityView(context: context)
+ } else {
+ MeshMapperLockScreenView(context: context)
+ }
+ }
+ .activityBackgroundTint(MeshMapperPalette.background)
+ .activitySystemActionForegroundColor(.white)
+ } dynamicIsland: { context in
+ DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ MeshMapperModeBadge(mode: context.state.mode)
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ MeshMapperIslandOutcome(state: context.state, isStale: context.isStale)
+ }
+ DynamicIslandExpandedRegion(.center) {
+ Text(context.state.zoneCode ?? context.state.connectionLabel)
+ .font(.caption2.weight(.semibold))
+ .foregroundStyle(context.isStale ? Color.orange : .secondary)
+ .lineLimit(1)
+ }
+ DynamicIslandExpandedRegion(.bottom) {
+ MeshMapperIslandBottom(state: context.state)
+ }
+ } compactLeading: {
+ Image(
+ systemName: context.isStale
+ ? "exclamationmark.triangle.fill" : context.state.phaseSymbol
+ )
+ .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor)
+ .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle)
+ } compactTrailing: {
+ MeshMapperCompactTrailing(state: context.state)
+ } minimal: {
+ if context.isStale {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ .accessibilityLabel("Update delayed")
+ } else {
+ // With room for one mark, the latest result matters more than mode
+ // or phase. This remains useful when an unanswered ping has no rows.
+ MeshMapperOutcomeDot(state: context.state, diameter: 9)
+ }
+ }
+ // The keyline is container chrome, not a second outcome indicator. A
+ // failed ping is routine and should not turn the whole island red.
+ .keylineTint(MeshMapperPalette.accent)
+ }
+ .meshMapperSupplementalActivityFamilies()
+ }
+}
+
+extension ActivityConfiguration {
+ fileprivate func meshMapperSupplementalActivityFamilies() -> some WidgetConfiguration {
+ if #available(iOSApplicationExtension 18.0, *) {
+ return supplementalActivityFamilies([.small])
+ } else {
+ return self
+ }
+ }
+}
+
+@available(iOSApplicationExtension 18.0, *)
+private struct MeshMapperResponsiveActivityView: View {
+ @Environment(\.activityFamily) private var activityFamily
+ let context: ActivityViewContext
+
+ var body: some View {
+ if activityFamily == .small {
+ MeshMapperSmallActivityContent(
+ state: context.state,
+ isStale: context.isStale
+ )
+ } else {
+ MeshMapperLockScreenContent(
+ state: context.state,
+ isStale: context.isStale
+ )
+ }
+ }
+}
+
+private struct MeshMapperLockScreenView: View {
+ let context: ActivityViewContext
+
+ var body: some View {
+ MeshMapperLockScreenContent(
+ state: context.state,
+ isStale: context.isStale
+ )
+ }
+}
+
+private struct MeshMapperLockScreenContent: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ let isStale: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 9) {
+ MeshMapperPhaseBar(
+ state: state,
+ titleFont: .subheadline.weight(.semibold),
+ countdownFont: .subheadline.monospacedDigit().weight(.semibold),
+ countdownWidth: 54
+ )
+
+ HStack(spacing: 8) {
+ MeshMapperModeBadge(mode: state.mode)
+ // Counts describe the session, so they belong beside its mode. Keeping
+ // them out of the repeater stack prevents the last row from reading as
+ // though TX, RX, and queue values belong to that individual node.
+ MeshMapperMetrics(state: state, compact: false)
+ Spacer(minLength: 6)
+ MeshMapperStatusLabel(state: state, isStale: isStale)
+ }
+
+ MeshMapperRepeaterSummary(
+ state: state,
+ limit: 3,
+ showsNames: true,
+ rowFont: .caption
+ )
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ .foregroundStyle(.white)
+ }
+}
+
+@available(iOSApplicationExtension 18.0, *)
+private struct MeshMapperSmallActivityContent: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ let isStale: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ MeshMapperPhaseBar(
+ state: state,
+ titleFont: .caption.weight(.semibold),
+ countdownFont: .caption2.monospacedDigit().weight(.bold),
+ countdownWidth: 40
+ )
+
+ HStack(spacing: 6) {
+ Text(state.mode.uppercased())
+ .font(.caption2.weight(.bold))
+ .tracking(0.6)
+ Spacer(minLength: 3)
+ if isStale {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.caption2)
+ .foregroundStyle(.orange)
+ .accessibilityLabel("Update delayed")
+ } else {
+ MeshMapperOutcomeDot(state: state, diameter: 7)
+ if let zone = state.zoneCode {
+ Text(zone)
+ .font(.system(.caption2, design: .monospaced).weight(.semibold))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ MeshMapperRepeaterSummary(
+ state: state,
+ limit: 2,
+ showsNames: false,
+ rowFont: .caption2
+ )
+
+ MeshMapperMetrics(state: state, compact: true)
+ }
+ .padding(12)
+ .foregroundStyle(.white)
+ }
+}
+
+/// The phase as a caption, a native countdown, and system-drawn progress.
+///
+/// **Nothing here animates and nothing here holds state.** It used to: a
+/// `@State` fraction was driven to zero by a `withAnimation` lasting the whole
+/// phase, which a Live Activity cannot honour. 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 simply stayed wherever the last ActivityKit
+/// update left it — observed still nearly full with nine seconds on the clock.
+///
+/// Both elements are now derived from absolute dates by the system, so every
+/// render the system chooses to make, on whatever schedule it likes, lands in
+/// the right place — and none of them costs this extension any work.
+private struct MeshMapperPhaseBar: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ let titleFont: Font
+ let countdownFont: Font
+ let countdownWidth: CGFloat
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 4) {
+ Text(state.phaseTitle)
+ .font(titleFont)
+ .foregroundStyle(.white.opacity(state.phaseDeadlineHasLapsed ? 0.45 : 1))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Spacer(minLength: 4)
+ // Fixed width because the native timer otherwise reserves room for its
+ // widest possible value and starves the title.
+ MeshMapperCountdown(state: state, font: countdownFont)
+ .frame(width: countdownWidth, alignment: .trailing)
+ }
+ MeshMapperPhaseProgress(state: state)
+ }
+ }
+}
+
+/// Elapsed phase progress, drawn by the system from the phase's own dates.
+///
+/// The range is the **whole phase**, not `now...deadline`: a range beginning at
+/// the current render would reset the bar to empty every time the system
+/// redrew the activity, which is the failure the hand-animated fill had in a
+/// different form.
+///
+/// It fills rather than drains, which is the trade for handing the work to
+/// `ProgressView`. The countdown beside it remains the authoritative statement
+/// of time remaining; the bar is the glanceable one.
+private struct MeshMapperPhaseProgress: View {
+ let state: MeshMapperActivityAttributes.ContentState
+
+ var body: some View {
+ if let range = state.phaseProgressRange {
+ ProgressView(timerInterval: range, countsDown: false) {
+ EmptyView()
+ } currentValueLabel: {
+ EmptyView()
+ }
+ .progressViewStyle(.linear)
+ // Progress says how far through the phase we are. Outcome has quieter,
+ // dedicated dots elsewhere and must not recolour the whole track.
+ .tint(MeshMapperPalette.accent)
+ } else {
+ // A durable state — disconnected, stopped, waiting for GPS — has no
+ // deadline to draw. The empty track keeps the block's height fixed, so
+ // arriving at one cannot reflow everything below it.
+ Capsule()
+ .fill(.white.opacity(0.16))
+ .frame(height: Self.trackHeight)
+ }
+ }
+
+ /// Matches the linear `ProgressView` track this stands in for.
+ private static let trackHeight: CGFloat = 4
+}
+
+private struct MeshMapperStatusLabel: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ let isStale: Bool
+
+ var body: some View {
+ Label(
+ isStale ? "Update delayed" : state.zoneCode ?? state.connectionLabel,
+ systemImage: isStale
+ ? "exclamationmark.triangle.fill"
+ : state.isConnected
+ ? "antenna.radiowaves.left.and.right"
+ : "wifi.slash"
+ )
+ .font(.caption2.weight(.semibold))
+ .lineLimit(1)
+ .foregroundStyle(isStale || !state.isConnected ? Color.orange : .secondary)
+ }
+}
+
+private struct MeshMapperRepeaterSummary: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ let limit: Int
+ let showsNames: Bool
+ let rowFont: Font
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 5) {
+ Text(state.repeatersAreCurrent ? "HEARD NOW" : "LAST HEARD")
+ .font(.caption2.weight(.bold))
+ .tracking(0.6)
+ if state.totalHeardCount > 0 {
+ Text("\(state.totalHeardCount)")
+ .font(.caption2.monospacedDigit().weight(.semibold))
+ }
+ }
+ .foregroundStyle(.secondary)
+
+ if state.repeaters.isEmpty {
+ HStack(spacing: 6) {
+ // A failed ping produces no rows, so its colour needs its own mark;
+ // absence alone cannot distinguish failure from no attempt yet.
+ MeshMapperOutcomeDot(state: state, diameter: 6)
+ Text(state.repeaterEmptyLabel)
+ .font(rowFont)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ } else {
+ ForEach(state.repeaters.prefix(limit)) { repeater in
+ MeshMapperRepeaterRow(
+ repeater: repeater,
+ fallbackColor: state.outcomeColor,
+ showsName: showsNames,
+ font: rowFont
+ )
+ }
+ }
+ }
+ }
+}
+
+private struct MeshMapperRepeaterRow: View {
+ let repeater: MeshMapperActivityAttributes.HeardRepeater
+ let fallbackColor: Color
+ let showsName: Bool
+ let font: Font
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Circle()
+ .fill(repeater.typeColor.map(Color.init) ?? fallbackColor)
+ .frame(width: 6, height: 6)
+ .accessibilityHidden(true)
+ // The path hash remains primary even when a friendly name resolves: it
+ // is the observation's identity and is what the map overlay names.
+ Text(repeater.id.uppercased())
+ .font(font.monospaced().weight(.semibold))
+ .lineLimit(1)
+ if showsName, let name = repeater.resolvedName {
+ Text(name)
+ .font(font)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ Spacer(minLength: 4)
+ Text(repeater.snr.formattedSnr)
+ .font(font.monospacedDigit().weight(.semibold))
+ .foregroundStyle(repeater.snrColor.map(Color.init) ?? .secondary)
+ .lineLimit(1)
+ }
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(repeater.accessibilitySummary)
+ }
+}
+
+private struct MeshMapperMetrics: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ let compact: Bool
+
+ var body: some View {
+ HStack(spacing: compact ? 9 : 11) {
+ Text("\(state.primaryMetricLabel) \(state.primaryMetricValue)")
+ Text("RX \(state.rxCount)")
+ if state.queueSize > 0 {
+ Label("\(state.queueSize)", systemImage: "arrow.triangle.2.circlepath")
+ }
+ }
+ .font(.caption2.monospacedDigit().weight(.medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+}
+
+private struct MeshMapperIslandBottom: View {
+ let state: MeshMapperActivityAttributes.ContentState
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ MeshMapperPhaseBar(
+ state: state,
+ titleFont: .caption.weight(.semibold),
+ countdownFont: .caption2.monospacedDigit().weight(.bold),
+ countdownWidth: 42
+ )
+ MeshMapperRepeaterSummary(
+ state: state,
+ limit: 2,
+ showsNames: false,
+ rowFont: .caption2
+ )
+ MeshMapperMetrics(state: state, compact: true)
+ }
+ }
+}
+
+private struct MeshMapperModeBadge: View {
+ let mode: String
+
+ var body: some View {
+ Label(mode.uppercased(), systemImage: "antenna.radiowaves.left.and.right")
+ .font(.caption2.weight(.bold))
+ .tracking(0.6)
+ .foregroundStyle(.white)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ // Mode is categorical, not signal data. Keeping its badge neutral leaves
+ // palette-resolved outcome, type, and SNR colours unambiguous.
+ .background(.white.opacity(0.16), in: Capsule())
+ }
+}
+
+private struct MeshMapperIslandOutcome: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ let isStale: Bool
+
+ var body: some View {
+ if isStale {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ .accessibilityLabel("Update delayed")
+ } else {
+ HStack(spacing: 5) {
+ MeshMapperOutcomeDot(state: state, diameter: 8)
+ Text(state.primaryMetricValue.formatted())
+ .font(.caption.monospacedDigit().weight(.semibold))
+ }
+ }
+ }
+}
+
+private struct MeshMapperOutcomeDot: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ let diameter: CGFloat
+
+ var body: some View {
+ Circle()
+ .fill(state.outcomeColor)
+ .frame(width: diameter, height: diameter)
+ .accessibilityLabel("Latest ping result")
+ }
+}
+
+/// **No state and no task**, for the reason `MeshMapperPhaseBar` states above:
+/// WidgetKit renders these views out of process and the extension "is not
+/// continually active, even if the widget is onscreen", so a `@State` flag
+/// retired by a sleeping `Task.sleep` is a promise this context cannot keep.
+///
+/// Nothing is lost by dropping it. `activeCountdownRange` is evaluated on every
+/// render and already returns nil once the deadline passes, so the branch below
+/// falls through to the SNR or the outcome dot exactly when it should — using
+/// the clock the system re-reads for us rather than a flag we hoped to update.
+private struct MeshMapperCompactTrailing: View {
+ let state: MeshMapperActivityAttributes.ContentState
+
+ var body: some View {
+ if state.activeCountdownRange != nil {
+ MeshMapperCountdown(
+ state: state,
+ isActive: true,
+ font: .caption2.monospacedDigit().weight(.bold)
+ )
+ .frame(minWidth: 28)
+ } else if let best = state.repeaters.first {
+ Text(best.snr.formattedSnr)
+ .font(.caption2.monospacedDigit().weight(.bold))
+ .foregroundStyle(best.snrColor.map(Color.init) ?? .primary)
+ .accessibilityLabel("Best SNR \(best.snr.formattedSnr)")
+ } else {
+ MeshMapperOutcomeDot(state: state, diameter: 8)
+ }
+ }
+}
+
+private struct MeshMapperCountdown: View {
+ let state: MeshMapperActivityAttributes.ContentState
+ var isActive: Bool = true
+ let font: Font
+
+ var body: some View {
+ if isActive, let range = state.activeCountdownRange {
+ Text(timerInterval: range, countsDown: true, showsHours: false)
+ .font(font)
+ .lineLimit(1)
+ .accessibilityLabel("Time remaining")
+ }
+ }
+}
+
+private enum MeshMapperPalette {
+ static let background = Color(red: 0.055, green: 0.075, blue: 0.105)
+ static let accent = Color.accentColor
+}
+
+extension MeshMapperActivityAttributes.ContentState {
+ fileprivate var activeCountdownRange: ClosedRange? {
+ let now = Date()
+ guard let phaseEndsAt, phaseEndsAt > now else { return nil }
+ return now...phaseEndsAt
+ }
+
+ /// The phase's own span, for a `ProgressView` that reads the clock itself.
+ ///
+ /// Deliberately anchored to the phase's start rather than to `now`, so a
+ /// system-initiated redraw resumes the bar where the clock says it is
+ /// instead of restarting it.
+ fileprivate var phaseProgressRange: ClosedRange? {
+ guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else {
+ return nil
+ }
+ let start = phaseEndsAt.addingTimeInterval(-Double(phaseDurationMs) / 1000)
+ guard start < phaseEndsAt else { return nil }
+ return start...phaseEndsAt
+ }
+
+ /// Evaluated at render time rather than retired by a sleeping task: a widget
+ /// that is not being redrawn has no one to show the change to, and the phase
+ /// transition that follows a deadline arrives as an urgent update anyway.
+ fileprivate var phaseDeadlineHasLapsed: Bool {
+ guard let phaseEndsAt else { return false }
+ return phaseEndsAt <= Date()
+ }
+
+ fileprivate var connectionLabel: String {
+ isConnected ? "Connected" : "Disconnected"
+ }
+
+ fileprivate var primaryMetricLabel: String {
+ switch mode.lowercased() {
+ case "passive": return "DISC"
+ case "trace": return "TRACE"
+ default: return "TX"
+ }
+ }
+
+ fileprivate var primaryMetricValue: Int {
+ switch mode.lowercased() {
+ case "passive": return discoveryCount
+ case "trace": return traceCount
+ default: return txCount
+ }
+ }
+
+ fileprivate var repeaterEmptyLabel: String {
+ switch phase {
+ case "listening", "listening_discovery", "listening_trace":
+ return "Nothing heard"
+ default:
+ return "Nothing heard in the last cycle"
+ }
+ }
+
+ fileprivate var phaseSymbol: String {
+ switch phase {
+ case "sending": return "arrow.up.circle.fill"
+ case "discovering": return "dot.radiowaves.left.and.right"
+ case "tracing": return "scope"
+ case "listening", "listening_discovery", "listening_trace": return "waveform"
+ case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return "timer"
+ case "skipped": return "forward.end.fill"
+ case "stopping", "stopped": return "stop.circle.fill"
+ case "waiting_for_gps": return "location.slash.fill"
+ case "paused_outside_zone": return "map.fill"
+ case "disconnected": return "wifi.slash"
+ case "tx_blocked": return "nosign"
+ case "starting": return "hourglass"
+ default: return "antenna.radiowaves.left.and.right"
+ }
+ }
+
+ /// Phone-resolved outcome colour wins whenever one exists. The phase colour
+ /// is only a fallback before a session has produced a ping result.
+ fileprivate var outcomeColor: Color {
+ if let pingColor { return Color(pingColor) }
+ return phaseColor
+ }
+
+ /// Colour for an element that describes the current phase rather than the
+ /// result of the most recent ping.
+ fileprivate var phaseColor: Color {
+ switch phase {
+ case "sending", "discovering", "tracing": return .blue
+ case "listening", "listening_discovery", "listening_trace": return .teal
+ case "waiting", "waiting_discovery", "waiting_trace", "cooldown": return .cyan
+ case "skipped", "waiting_for_gps", "paused_outside_zone": return .orange
+ case "disconnected", "tx_blocked": return .red
+ case "stopped": return .gray
+ default: return .white
+ }
+ }
+}
+
+extension MeshMapperActivityAttributes.HeardRepeater {
+ fileprivate var resolvedName: String? {
+ let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !trimmed.isEmpty, trimmed.caseInsensitiveCompare(id) != .orderedSame else {
+ return nil
+ }
+ return trimmed
+ }
+
+ fileprivate var accessibilitySummary: String {
+ let identity = resolvedName.map { "\(id), \($0)" } ?? id
+ return "\(identity), SNR \(snr.formattedSnr)"
+ }
+}
+
+extension MeshMapperActivityAttributes.ResolvedColor {
+ fileprivate var swiftUIColor: Color {
+ Color(.sRGB, red: r, green: g, blue: b, opacity: 1)
+ }
+}
+
+extension Color {
+ fileprivate init(_ resolved: MeshMapperActivityAttributes.ResolvedColor) {
+ self = resolved.swiftUIColor
+ }
+}
+
+extension Double {
+ fileprivate var formattedSnr: String {
+ let sign = self >= 0 ? "+" : ""
+ return "\(sign)\(formatted(.number.precision(.fractionLength(1)))) dB"
+ }
+}
+
+#if DEBUG
+extension MeshMapperActivityAttributes.ContentState {
+ fileprivate static var previewRunningWithResponses: Self {
+ Self(
+ mode: "Hybrid",
+ phase: "listening",
+ phaseTitle: "Listening…",
+ phaseDetail: "Waiting for repeater echoes",
+ phaseEndsAt: Date().addingTimeInterval(42),
+ phaseDurationMs: 60_000,
+ pingColor: .init(r: 0.20, g: 0.84, b: 0.45),
+ isConnected: true,
+ zoneCode: "SEA",
+ txCount: 42,
+ rxCount: 318,
+ discoveryCount: 8,
+ traceCount: 0,
+ queueSize: 2,
+ repeaters: [
+ .init(
+ id: "A61F",
+ name: "Capitol Hill",
+ snr: 12.4,
+ typeColor: .init(r: 0.20, g: 0.84, b: 0.45),
+ snrColor: .init(r: 0.34, g: 0.90, b: 0.44)
+ ),
+ .init(
+ id: "0B73",
+ name: "Lake Union",
+ snr: 2.1,
+ typeColor: .init(r: 0.22, g: 0.80, b: 0.78),
+ snrColor: .init(r: 0.96, g: 0.76, b: 0.22)
+ ),
+ .init(
+ id: "91CE",
+ name: nil,
+ snr: -8.7,
+ typeColor: .init(r: 0.64, g: 0.42, b: 0.94),
+ snrColor: .init(r: 0.94, g: 0.30, b: 0.28)
+ ),
+ ],
+ totalHeardCount: 5,
+ repeatersAreCurrent: true,
+ updatedAt: Date()
+ )
+ }
+
+ fileprivate static var previewRunningWithNoneHeard: Self {
+ Self(
+ mode: "Active",
+ phase: "listening",
+ phaseTitle: "Listening…",
+ phaseDetail: "Waiting for repeater echoes",
+ phaseEndsAt: Date().addingTimeInterval(25),
+ phaseDurationMs: 60_000,
+ pingColor: .init(r: 0.94, g: 0.28, b: 0.25),
+ isConnected: true,
+ zoneCode: "SEA",
+ txCount: 17,
+ rxCount: 3,
+ discoveryCount: 0,
+ traceCount: 0,
+ queueSize: 0,
+ repeaters: [],
+ totalHeardCount: 0,
+ repeatersAreCurrent: true,
+ updatedAt: Date()
+ )
+ }
+
+ fileprivate static var previewLapsedDeadline: Self {
+ var state = previewRunningWithResponses
+ state.phaseEndsAt = Date().addingTimeInterval(-5)
+ state.repeatersAreCurrent = false
+ return state
+ }
+}
+
+private struct MeshMapperActivityPreview: View {
+ let state: MeshMapperActivityAttributes.ContentState
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 18) {
+ MeshMapperLockScreenContent(state: state, isStale: false)
+ .background(MeshMapperPalette.background)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+
+ // The island's regions are composed by the system at runtime. Showing
+ // their shared bottom region here keeps its bar and rows inspectable
+ // without requiring a BLE-backed ActivityKit session.
+ MeshMapperIslandBottom(state: state)
+ .padding(12)
+ .foregroundStyle(.white)
+ .background(.black)
+ .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+
+ if #available(iOSApplicationExtension 18.0, *) {
+ MeshMapperSmallActivityContent(state: state, isStale: false)
+ .frame(width: 180)
+ .background(MeshMapperPalette.background)
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+ }
+ }
+ .padding()
+ }
+ .preferredColorScheme(.dark)
+ }
+}
+
+/// Renders each layout to a PNG so the design can be reviewed without a live
+/// session.
+///
+/// A Live Activity needs ActivityKit to construct its context, and ActivityKit
+/// needs a session, which needs BLE — which the simulator does not have. That
+/// left three layouts reviewable only on a wrist. The content views take a
+/// plain `ContentState`, so `ImageRenderer` can draw them headlessly instead.
+///
+/// **`ImageRenderer` cannot draw the phase progress bar, and its failure looks
+/// like a bug in the bar.** A `ProgressView(timerInterval:)` comes out as a
+/// full-width track with a no-entry glyph in the middle, identically at every
+/// remaining fraction — `Text(timerInterval:)` beside it snapshots fine, which
+/// makes the difference easy to misread as ours. Hosting the same views in a
+/// live `UIHostingController` in the simulator draws them correctly and
+/// advances them with no state update at all: 0:40 of a 60 s phase at 33 %,
+/// 0:23 at 62 %, 0:05 at 92 %. Review the bar that way, not through here.
+///
+/// DEBUG-only, and reached solely from a launch argument.
+enum MeshMapperActivityRenderHarness {
+ /// Widths are the real ones: a Live Activity on the lock screen spans about
+ /// 360 pt, the watch's small family about 184, and the island's shared bottom
+ /// region about 360.
+ @MainActor
+ static func writeRenders(to directory: URL) -> [String] {
+ let states: [(String, MeshMapperActivityAttributes.ContentState)] = [
+ ("responses", .previewRunningWithResponses),
+ ("none-heard", .previewRunningWithNoneHeard),
+ ("lapsed", .previewLapsedDeadline),
+ ]
+
+ var written: [String] = []
+ for (name, state) in states {
+ written += [
+ render(
+ MeshMapperLockScreenContent(state: state, isStale: false)
+ .frame(width: 360)
+ .background(MeshMapperPalette.background),
+ named: "lock-\(name)", in: directory
+ ),
+ render(
+ MeshMapperIslandBottom(state: state)
+ .padding(12)
+ .foregroundStyle(.white)
+ .frame(width: 360)
+ .background(.black),
+ named: "island-\(name)", in: directory
+ ),
+ ].compactMap { $0 }
+
+ if #available(iOS 18.0, *) {
+ if let path = render(
+ MeshMapperSmallActivityContent(state: state, isStale: false)
+ .frame(width: 184)
+ .background(MeshMapperPalette.background),
+ named: "small-\(name)", in: directory
+ ) {
+ written.append(path)
+ }
+ }
+ }
+ return written
+ }
+
+ @MainActor
+ private static func render(
+ _ view: some View, named name: String, in directory: URL
+ ) -> String? {
+ let renderer = ImageRenderer(content: view.environment(\.colorScheme, .dark))
+ renderer.scale = 3
+ guard let data = renderer.uiImage?.pngData() else { return nil }
+ let url = directory.appendingPathComponent("\(name).png")
+ try? data.write(to: url)
+ return url.path
+ }
+}
+
+#Preview("Running — responses") {
+ MeshMapperActivityPreview(state: .previewRunningWithResponses)
+}
+
+#Preview("Running — none heard") {
+ MeshMapperActivityPreview(state: .previewRunningWithNoneHeard)
+}
+
+#Preview("Lapsed deadline") {
+ MeshMapperActivityPreview(state: .previewLapsedDeadline)
+}
+#endif
diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift
new file mode 100644
index 0000000..08195fe
--- /dev/null
+++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import WidgetKit
+
+@main
+struct MeshMapperLiveActivityBundle: WidgetBundle {
+ var body: some Widget {
+ MeshMapperLiveActivity()
+ }
+}
diff --git a/ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..6e000ca
--- /dev/null
+++ b/ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xC7",
+ "green" : "0x54",
+ "red" : "0x7D"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..88819d5
--- /dev/null
+++ b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "Icon-Watch-1024x1024.png",
+ "idiom" : "universal",
+ "platform" : "watchos",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png
new file mode 100644
index 0000000..ee5cc0c
Binary files /dev/null and b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png differ
diff --git a/ios/MeshMapperWatch/Assets.xcassets/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/MeshMapperWatch/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift
new file mode 100644
index 0000000..f8b67a9
--- /dev/null
+++ b/ios/MeshMapperWatch/ContentView.swift
@@ -0,0 +1,75 @@
+import SwiftUI
+
+/// Root shell.
+///
+/// The map is always page one and full-bleed, with controls immediately after
+/// it. Where the heard-node list lives is a presentation choice — a sheet over
+/// the map, or its own page — driven by `WatchSettings.nodeListPlacement`.
+/// Both read the same `NodeListView`, so this is a toggle rather than two
+/// implementations.
+struct ContentView: View {
+ @Environment(WatchSettings.self) private var settings
+
+ @State private var selection = Self.requestedInitialPage
+
+ /// Page to open on, so one can be captured headlessly for design review —
+ /// the simulator offers no way to swipe.
+ ///
+ /// Supplied as the state's initial value rather than assigned in `onAppear`.
+ /// A `.verticalPage` `TabView` ignores a selection change made that late, so
+ /// the assignment silently did nothing and every capture landed on the map.
+ private static var requestedInitialPage: Int {
+ #if DEBUG
+ return max(0, UserDefaults.standard.integer(forKey: "MeshMapperInitialPage"))
+ #else
+ return 0
+ #endif
+ }
+
+ var body: some View {
+ // A vertical-page TabView already installs watchOS's per-page navigation
+ // hosting. Putting another NavigationStack inside one of those pages nests
+ // wrapped controllers and aborts in PUICStackedNavigationBar.layoutSubviews.
+ // Keep the app's one stack outside the pager so toolbar items have a host
+ // without making any individual page create a second one.
+ NavigationStack {
+ TabView(selection: $selection) {
+ MapPage(isSelected: selection == 0).tag(0)
+ ControlsPage().tag(1)
+
+ // The sheet placement is opened by tapping the map's status panel, which
+ // the readout does not have — so with Readout selected, honouring "sheet"
+ // would leave the heard list with no way in at all. A choice of main page
+ // must not make a feature unreachable, so the page appears regardless.
+ if settings.nodeListPlacement == .page || settings.mainPageContent == .readout {
+ NodeListView()
+ .navigationTitle("Heard")
+ .navigationBarTitleDisplayMode(.inline)
+ .tag(2)
+ }
+
+ #if DEBUG
+ // This page exposes raw, low-friction verification controls. Keeping
+ // it out of Release is a transmit-safety boundary, not page polish.
+ DebugPage().tag(3)
+ #endif
+ SettingsPage().tag(4)
+ }
+ .tabViewStyle(.verticalPage)
+ #if DEBUG
+ /// Drive a page change, because the simulator offers no way to swipe.
+ ///
+ /// Page transitions are a real defect surface here — a sheet raised from
+ /// the map once survived onto the next page, where it blurred that page
+ /// and swallowed all input, which reads exactly like a crash. This makes
+ /// that class reproducible without a wrist.
+ .task {
+ let target = UserDefaults.standard.integer(forKey: "MeshMapperAutoPageTo")
+ guard target > 0 else { return }
+ try? await Task.sleep(for: .seconds(5))
+ selection = target
+ }
+ #endif
+ }
+ }
+}
diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift
new file mode 100644
index 0000000..37196f5
--- /dev/null
+++ b/ios/MeshMapperWatch/ControlsPage.swift
@@ -0,0 +1,318 @@
+import SwiftUI
+
+/// Session controls sized for deliberate use while moving.
+///
+/// Availability is only the phone's last-reported state. The watch does not
+/// duplicate transport, GPS, cooldown, or session guards; every tap still goes
+/// to the phone, which revalidates it and returns the reason when refused.
+struct ControlsPage: View {
+ @Environment(WatchSessionClient.self) private var client
+ @Environment(WatchSettings.self) private var settings
+
+ @State private var pingArmed = false
+ @State private var disarmPingTask: Task?
+
+ private static let minimumTapHeight: CGFloat = 44
+ private static let compactLabelHeight: CGFloat = 24
+
+ /// The explanatory text scales with the wearer's text-size setting; the
+ /// controls around it do not.
+ ///
+ /// **`Font.system(size:)` is a fixed point size and ignores Dynamic Type
+ /// entirely**, so the page-level `dynamicTypeSize` clamp that used to sit
+ /// here bounded nothing — every string on this page was 10 or 13 points at
+ /// every setting, including the largest accessibility sizes. `@ScaledMetric`
+ /// keeps the tuned 10 pt base and makes it grow, which is the whole point:
+ /// `blockedReason` is the one thing that explains a dead button, and at
+ /// 10 pt in 45 % white it was the least legible text in the app.
+ ///
+ /// Deliberately unbounded, following `NodeListView`: fewer rows at a large
+ /// setting is the correct outcome, and this page already scrolls. The
+ /// buttons keep their fixed labels so the hardware-checked 44 pt targets and
+ /// single-line titles survive — a narrower deviation, and a stated one.
+ @ScaledMetric(relativeTo: .caption2) private var reasonFontSize: CGFloat = 10
+
+ private var controls: WatchControls? { client.snapshot?.controls }
+
+ private var effectiveStartMode: WatchSettings.DefaultStartMode {
+ settings.effectiveStartMode(
+ availableStartModes: client.snapshot?.availableStartModes
+ )
+ }
+
+ /// Manual-ping cooldown deadline, if one is still ahead of us.
+ private var cooldownEndsAt: Date? {
+ guard let ms = controls?.manualCooldownEndsAtMs else { return nil }
+ let endsAt = Date(timeIntervalSince1970: ms / 1000)
+ return endsAt > Date() ? endsAt : nil
+ }
+
+ var body: some View {
+ ScrollView {
+ // No page title. The buttons name themselves, and on a 40 mm screen a
+ // header pushed `blockedReason` — the one thing that explains a dead
+ // button — below the fold, which is the opposite of what it is for. The
+ // compact in-card context adds meaning without spending a navigation row.
+ VStack(spacing: 5) {
+ sessionHeader
+ startStopControl
+ manualPingControl
+
+ // A fresh cue retained across a watch-process restart has no local tap
+ // to name. Keep that rare but valid feedback visible without guessing
+ // which control owns it.
+ if client.lastRefusalCommand == nil,
+ let refusal = client.lastRefusal
+ {
+ refusalMessage(refusal)
+ }
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 5)
+ .background(
+ .ultraThinMaterial,
+ in: .rect(cornerRadius: WatchPalette.cornerRadius, style: .continuous)
+ )
+ .overlay(
+ RoundedRectangle(
+ cornerRadius: WatchPalette.cornerRadius,
+ style: .continuous
+ )
+ .stroke(.white.opacity(0.12), lineWidth: 0.5)
+ )
+ .padding(.horizontal, 6)
+ .padding(.top, 2)
+ // The last line must not finish against the bottom edge: the display
+ // curves there, and the simulator's flat rectangle has hidden exactly
+ // this twice before.
+ .padding(.bottom, 14)
+ }
+ // No page-level type clamp. It read as protecting the tap targets, but the
+ // controls it was protecting use fixed point sizes and were never at risk;
+ // all it actually did was cap `reasonFontSize` at `.large` and stop the one
+ // piece of reading content on the page from reaching accessibility sizes.
+ .opacity(client.isStale ? 0.5 : 1.0)
+ .onChange(of: controls?.canManualPing) { _, canManualPing in
+ if canManualPing != true { disarmPing() }
+ }
+ .onDisappear { disarmPing() }
+ }
+
+ private var sessionHeader: some View {
+ HStack(alignment: .firstTextBaseline, spacing: 0) {
+ // The mode doubles as the compact section label. A separate "SESSION"
+ // row would spend the exact vertical space that keeps a two-line blocked
+ // reason visible on the 40 mm display.
+ Text(controls?.isSessionActive == true
+ ? (client.snapshot?.mode.uppercased() ?? "SESSION")
+ : "IDLE")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.55))
+ .lineLimit(1)
+ .fixedSize(horizontal: true, vertical: false)
+
+ Spacer(minLength: 4)
+
+ Text(client.snapshot?.phaseTitle ?? "Waiting for iPhone")
+ // Phase titles are prose, not an identity or numeric value. The
+ // proportional face plus bounded scaling keeps even "Device
+ // disconnected" and "Listening for trace…" intact on 40 mm without
+ // buying that width by pushing the blocked reason below the fold.
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.white)
+ .lineLimit(1)
+ .minimumScaleFactor(0.6)
+ .allowsTightening(true)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var startStopControl: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ startStopButton
+ if controls?.canStartStop != true,
+ let reason = controls?.blockedReason
+ {
+ controlReason(reason)
+ }
+ if let refusal = startStopRefusal {
+ refusalMessage(refusal)
+ }
+ }
+ }
+
+ private var manualPingControl: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ manualPingButton
+ // When Start/Stop is also unavailable, its action owns the shared reason
+ // above. Otherwise keep the explanation immediately under Manual ping.
+ if controls?.canStartStop == true,
+ controls?.canManualPing != true,
+ let reason = controls?.blockedReason
+ {
+ controlReason(reason)
+ }
+ if client.lastRefusalCommand == .manualPing,
+ let refusal = client.lastRefusal
+ {
+ refusalMessage(refusal)
+ }
+ }
+ }
+
+ private var startStopRefusal: String? {
+ switch client.lastRefusalCommand {
+ case .startSession, .stopSession:
+ return client.lastRefusal
+ default:
+ return nil
+ }
+ }
+
+ private func controlReason(_ reason: String) -> some View {
+ Text(reason)
+ .font(.system(size: reasonFontSize, weight: .medium))
+ .foregroundStyle(.white.opacity(0.45))
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 4)
+ }
+
+ private func refusalMessage(_ refusal: String) -> some View {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Image(systemName: "exclamationmark.circle.fill")
+ Text(refusal)
+ }
+ .font(.system(size: reasonFontSize, weight: .medium))
+ .foregroundStyle(WatchPalette.armed)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 4)
+ }
+
+ private var startStopButton: some View {
+ let isActive = controls?.isSessionActive ?? false
+ let kind: WatchCommand.Kind = isActive ? .stopSession : .startSession
+ let isPending = client.pendingCommand == kind
+ let isEnabled = controls?.canStartStop == true && !isPending
+ let startTitle = "Start \(effectiveStartMode.label)"
+ let buttonTitle = isPending
+ ? (isActive ? "Stopping…" : "Starting…")
+ : (isActive ? "Stop" : startTitle)
+
+ return Button {
+ if kind == .startSession {
+ client.send(kind, mode: effectiveStartMode.rawValue)
+ } else {
+ client.send(kind)
+ }
+ } label: {
+ Text(buttonTitle)
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .frame(maxWidth: .infinity, minHeight: Self.compactLabelHeight)
+ // A ProgressView accepts the horizontal slack offered by a stack. As
+ // an overlay it can appear without participating in the label's
+ // centring, so feedback never makes the action jump under a thumb.
+ .overlay(alignment: .leading) {
+ if isPending {
+ ProgressView()
+ .controlSize(.small)
+ .frame(width: 16, height: 16)
+ .padding(.leading, 6)
+ }
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ // The prominent watch style supplies substantial chrome outside its
+ // label. Giving the label the full ergonomic floor made the finished
+ // target 69.5 pt tall; applying that floor to the styled Button preserves
+ // the moving-vehicle tap target without paying for it twice.
+ .frame(minHeight: Self.minimumTapHeight)
+ .buttonBorderShape(.roundedRectangle(radius: WatchPalette.cornerRadius))
+ .tint(isEnabled
+ ? (isActive ? WatchPalette.stop : WatchPalette.start)
+ : WatchPalette.disabled)
+ .disabled(!isEnabled)
+ }
+
+ private var manualPingButton: some View {
+ let isPending = client.pendingCommand == .manualPing
+ let isEnabled = controls?.canManualPing == true && !isPending
+
+ return Button {
+ if pingArmed {
+ disarmPing()
+ client.send(.manualPing)
+ } else {
+ armPing()
+ }
+ } label: {
+ pingLabel(isPending: isPending)
+ .frame(maxWidth: .infinity, minHeight: Self.compactLabelHeight)
+ // Keep this identical to Start/Stop: pending feedback belongs at the
+ // edge of the target, not in the row that determines its label's centre.
+ .overlay(alignment: .leading) {
+ if isPending {
+ ProgressView()
+ .controlSize(.small)
+ .frame(width: 16, height: 16)
+ .padding(.leading, 6)
+ }
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .frame(minHeight: Self.minimumTapHeight)
+ .buttonBorderShape(.roundedRectangle(radius: WatchPalette.cornerRadius))
+ .tint(isEnabled
+ ? (pingArmed ? WatchPalette.armed : WatchPalette.ping)
+ : WatchPalette.disabled)
+ .disabled(!isEnabled)
+ }
+
+ /// The ping button's label, as exactly one view.
+ ///
+ /// A `Group` will not do here: with two children it applies each modifier to
+ /// both, so `maxWidth: .infinity` went to the words *and* the timer and threw
+ /// them to opposite ends of the button. The pair has to be its own stack to
+ /// read as one centred label.
+ ///
+ /// A cooldown is the one unavailability the phone reports without a
+ /// `blockedReason`, so without this the button would sit dead and unexplained.
+ /// The deadline is absolute, so the countdown stays right even if no further
+ /// snapshot arrives.
+ @ViewBuilder
+ private func pingLabel(isPending: Bool) -> some View {
+ if let endsAt = cooldownEndsAt {
+ HStack(spacing: 5) {
+ Text("Ping in")
+ Text(timerInterval: Date()...endsAt, countsDown: true)
+ .monospacedDigit()
+ .frame(width: 38, alignment: .leading)
+ }
+ .font(.system(size: 13, weight: .semibold))
+ } else {
+ Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping"))
+ .font(.system(size: 13, weight: .semibold))
+ }
+ }
+
+ /// The first tap buys a short confirmation window; expiry returns the button
+ /// to a harmless state without involving the phone or transmitting anything.
+ private func armPing() {
+ disarmPingTask?.cancel()
+ pingArmed = true
+ disarmPingTask = Task { @MainActor in
+ try? await Task.sleep(for: .seconds(3))
+ guard !Task.isCancelled else { return }
+ pingArmed = false
+ disarmPingTask = nil
+ }
+ }
+
+ private func disarmPing() {
+ disarmPingTask?.cancel()
+ disarmPingTask = nil
+ pingArmed = false
+ }
+}
diff --git a/ios/MeshMapperWatch/DebugPage.swift b/ios/MeshMapperWatch/DebugPage.swift
new file mode 100644
index 0000000..e94a614
--- /dev/null
+++ b/ios/MeshMapperWatch/DebugPage.swift
@@ -0,0 +1,193 @@
+import SwiftUI
+
+#if DEBUG
+/// Raw state dump and command buttons.
+///
+/// Kept from Phase 2 as a development surface while the real UI is built out.
+/// Phase 5 replaces the buttons with the proper controls page; this page goes
+/// away once the node list and controls carry their own verification.
+struct DebugPage: View {
+ @Environment(WatchSessionClient.self) private var client
+ @Environment(WatchSettings.self) private var settings
+
+ @State private var pingArmed = false
+ @State private var disarmPingTask: Task?
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 8) {
+ header
+
+ if client.versionMismatch {
+ Text("Update MeshMapper on iPhone — wire version mismatch")
+ .font(.caption2)
+ .foregroundStyle(.orange)
+ }
+
+ if let snapshot = client.snapshot {
+ session(snapshot)
+ counters(snapshot)
+ geo(snapshot)
+ controls(snapshot)
+ } else {
+ Text("No snapshot yet")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+
+ commandButtons
+
+ if let refusal = client.lastRefusal {
+ Text(refusal)
+ .font(.caption2)
+ .foregroundStyle(.orange)
+ }
+ }
+ .padding(.horizontal, 4)
+ .opacity(client.isStale ? 0.45 : 1.0)
+ }
+ .onDisappear { disarmPing() }
+ }
+
+ private var header: some View {
+ HStack {
+ Circle()
+ .fill(client.isReachable ? .green : .gray)
+ .frame(width: 6, height: 6)
+ Text(client.isReachable ? "Reachable" : "Unreachable")
+ .font(.caption2)
+ Spacer()
+ if let receivedAt = client.receivedAt {
+ Text(receivedAt, style: .relative)
+ .font(.system(size: 9))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private func session(_ s: WatchSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(s.phaseTitle).font(.headline).lineLimit(2)
+ if let detail = s.phaseDetail {
+ Text(detail).font(.caption2).foregroundStyle(.secondary)
+ }
+ HStack(spacing: 4) {
+ if let color = s.pingColor {
+ Circle().fill(Color(color)).frame(width: 8, height: 8)
+ }
+ Text(s.mode).font(.caption2)
+ if let endsAt = s.phaseEndsAt, endsAt > Date() {
+ Text(timerInterval: Date()...endsAt, countsDown: true)
+ .font(.caption.monospacedDigit())
+ }
+ }
+ }
+ }
+
+ private func counters(_ s: WatchSnapshot) -> some View {
+ Text("TX \(s.txCount) · RX \(s.rxCount) · DISC \(s.discoveryCount) · Q \(s.queueSize)")
+ .font(.system(size: 10).monospacedDigit())
+ .foregroundStyle(.secondary)
+ }
+
+ private func geo(_ s: WatchSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ if let you = s.geo.you {
+ Text(String(format: "%.5f, %.5f", you.lat, you.lon))
+ .font(.system(size: 10).monospacedDigit())
+ } else {
+ Text("No GPS fix").font(.system(size: 10)).foregroundStyle(.secondary)
+ }
+ Text("pings \(s.geo.pings.count) · rptrs \(s.geo.repeaters.count) · heard \(s.geo.heard.count)")
+ .font(.system(size: 10).monospacedDigit())
+ .foregroundStyle(.secondary)
+
+ ForEach(s.geo.heard) { node in
+ HStack(spacing: 4) {
+ Circle().fill(Color(node.typeColor)).frame(width: 5, height: 5)
+ Text(node.id)
+ .font(.system(size: 10, design: .monospaced))
+ if let name = node.name {
+ Text(name)
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ Spacer()
+ if let snr = node.snr {
+ Text(String(format: "%.1f", snr))
+ .font(.system(size: 10).monospacedDigit())
+ }
+ }
+ }
+ }
+ }
+
+ private func controls(_ s: WatchSnapshot) -> some View {
+ VStack(alignment: .leading, spacing: 1) {
+ Text("start/stop \(s.controls.canStartStop ? "✓" : "✗") · ping \(s.controls.canManualPing ? "✓" : "✗")")
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ if let reason = s.controls.blockedReason {
+ Text(reason).font(.system(size: 10)).foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private var commandButtons: some View {
+ VStack(spacing: 4) {
+ Button(sessionActive ? "Stop" : "Start \(effectiveStartMode.label)") {
+ if sessionActive {
+ client.send(.stopSession)
+ } else {
+ client.send(.startSession, mode: effectiveStartMode.rawValue)
+ }
+ }
+ .disabled(!(client.snapshot?.controls.canStartStop ?? false))
+
+ Button(pingArmed ? "Send ping?" : "Manual ping") {
+ if pingArmed {
+ disarmPing()
+ client.send(.manualPing)
+ } else {
+ armPing()
+ }
+ }
+ .disabled(!(client.snapshot?.controls.canManualPing ?? false))
+
+ Button("Refresh") { client.send(.requestSnapshot) }
+ }
+ .font(.caption2)
+ .buttonStyle(.bordered)
+ }
+
+ private var sessionActive: Bool {
+ client.snapshot?.controls.isSessionActive ?? false
+ }
+
+ private var effectiveStartMode: WatchSettings.DefaultStartMode {
+ settings.effectiveStartMode(
+ availableStartModes: client.snapshot?.availableStartModes
+ )
+ }
+
+ /// DEBUG still reaches physical development watches, so its raw-state page
+ /// keeps the same deliberate second tap as every shipping ping surface.
+ private func armPing() {
+ disarmPingTask?.cancel()
+ pingArmed = true
+ disarmPingTask = Task { @MainActor in
+ try? await Task.sleep(for: .seconds(3))
+ guard !Task.isCancelled else { return }
+ pingArmed = false
+ disarmPingTask = nil
+ }
+ }
+
+ private func disarmPing() {
+ disarmPingTask?.cancel()
+ disarmPingTask = nil
+ pingArmed = false
+ }
+}
+#endif
diff --git a/ios/MeshMapperWatch/Info.plist b/ios/MeshMapperWatch/Info.plist
new file mode 100644
index 0000000..1e2b71b
--- /dev/null
+++ b/ios/MeshMapperWatch/Info.plist
@@ -0,0 +1,38 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ MeshMapper
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ MeshMapper
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ WKApplication
+
+ WKCompanionAppBundleIdentifier
+ $(MESHMAPPER_BUNDLE_PREFIX)
+
+ WKSupportsLiveActivityLaunchAttributeTypes
+
+ WKRunsIndependentlyOfCompanionApp
+
+
+
diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift
new file mode 100644
index 0000000..d7e2bb4
--- /dev/null
+++ b/ios/MeshMapperWatch/MapPage.swift
@@ -0,0 +1,2587 @@
+import CoreLocation
+import MapKit
+import SwiftUI
+import WatchKit
+
+/// The map, drawn on Apple's basemap.
+///
+/// MeshMapper's own basemap cannot come along: `MKTileOverlay` is
+/// `API_UNAVAILABLE(watchos)`, so the OpenFreeMap styles, the ArcGIS satellite
+/// raster, and the coverage vector tiles have no route onto the wrist. Only
+/// the data layer — ping colours, repeater pins, the fix — is MeshMapper's.
+/// Apple's `.imagery` also renders pixel-identically to `.standard` here,
+/// verified by pixel diff on device and simulator, so the watch exposes no
+/// satellite mode.
+struct MapPage: View {
+ /// TabView may retain neighbouring pages. The selected tag is the reliable
+ /// signal that this page is actually visible; view lifecycle alone cannot
+ /// tell the phone whether its marker payload is useful.
+ let isSelected: Bool
+
+ @Environment(WatchSessionClient.self) private var client
+ @Environment(WatchSettings.self) private var settings
+ @Environment(\.isLuminanceReduced) private var environmentLuminanceReduced
+
+ /// Always-On cannot be entered in the simulator, and on hardware it needs a
+ /// wrist-down device — so the dimmed layout was unreviewable. This lets it be
+ /// captured headlessly, like the sample-data affordances.
+ private var isLuminanceReduced: Bool {
+ #if DEBUG
+ if debugForcedDim { return true }
+ if UserDefaults.standard.bool(forKey: "MeshMapperForceDimmed") { return true }
+ #endif
+ return environmentLuminanceReduced
+ }
+
+ #if DEBUG
+ /// Drives a real full-luminance -> dimmed *transition* in the simulator,
+ /// which `MeshMapperForceDimmed` alone cannot do: that flag only sets the
+ /// state the page is born into, so it exercises the cold-dimmed path and
+ /// never the glance path. Everything about the dim hold lives in the
+ /// transition, so without this the only way to test it is Adam's wrist.
+ ///
+ /// Launch with `-MeshMapperAutoDimAfter `.
+ @State private var debugForcedDim = false
+ #endif
+
+ /// Content choice and display cadence are independent. A chosen readout can
+ /// run at full luminance with a precise timer, and reduced luminance settles
+ /// on that same approved surface because MapKit is not worth holding through
+ /// a wrist-down.
+ ///
+ /// It settles there *after* the hold, not at the dim — see `dimmedMapHold`.
+ private var showsMap: Bool {
+ settings.mainPageContent == .map
+ && (!isLuminanceReduced || isWithinDimmedMapHold)
+ }
+
+ /// Whether the phone should keep sending map geography.
+ ///
+ /// **Deliberately not `showsMap && isSelected`.** The hold below keeps
+ /// drawing the pings the watch already has; it must not also keep the
+ /// phone's geo claim open. `WatchBridgeService` only schedules suppression
+ /// 15 s out, so a glance already costs about 21 s of geo — 5.9 s of map plus
+ /// that delay — and holding the claim for the hold's duration as well would
+ /// add the whole of `dimmedMapHold` on top, for a map the wearer was looking
+ /// at either way. Stated as a ratio this used to read "roughly double"; the
+ /// hold is now 12 s rather than 20, so it is nearer half again — the argument
+ /// is unchanged, only the arithmetic.
+ private var needsMapGeo: Bool {
+ settings.mainPageContent == .map && !isLuminanceReduced && isSelected
+ }
+
+ /// How long the map keeps drawing after the display dims.
+ ///
+ /// **The dim is not the wrist coming down.** watchOS drops to reduced
+ /// luminance about 5.9 seconds into a glance — 33 lifts measured, 14 of them
+ /// inside a 0.49 s band, seven between 5.88 and 5.91 s — and the *clock face*
+ /// dims in about the same time, so this is the platform's full-brightness
+ /// window rather than anything this app can hold on to. The display stays
+ /// legible: "the screen does get a bit dimmer but the level is relatively
+ /// high still."
+ ///
+ /// `showsMap` treated that as nobody looking and tore the whole MapKit
+ /// subtree down mid-glance. The wearer's report: "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."
+ ///
+ /// **Twelve seconds, down from twenty, and the cost model behind that has
+ /// been corrected.** The earlier note priced the hold as "MapKit stays
+ /// constructed and rendering for the duration". It does not: watchOS suspends
+ /// this app about a second into a wrist-down — 7.42 s lost of an 8.20 s drop,
+ /// 12.89 of 13.63, 23.01 of 23.99 — so almost none of that rendering happens.
+ /// What the hold actually buys is a bright basemap held on an OLED always-on
+ /// display, and that cost is paid whether or not the app is scheduled.
+ ///
+ /// The dim lands 5.9 s in, so twelve seconds keeps the map up until nearly
+ /// 18 s into a glance, which still covers a deliberate one with room to
+ /// spare. Twenty spent most of its length showing a map to nobody: a typical
+ /// glance is over long before 26 s. The scrim in `map` cuts what each
+ /// remaining second costs; this cuts how many of them there are.
+ ///
+ /// It leaves the *radio* alone — `needsMapGeo` above is untouched by the
+ /// hold.
+ ///
+ /// The floor is the flicker this exists to prevent. Do not take it below the
+ /// 5.9 s dim by much, or `showsMap` starts tearing the subtree down inside a
+ /// glance again, which is the defect the wearer reported: "the map flicker on
+ /// and disappear while a user is looking at it."
+ private static let dimmedMapHold: TimeInterval = 12
+
+
+ /// When the display last dimmed, or nil at full luminance.
+ @State private var dimmedAt: Date?
+
+ /// Exists to make a render happen at the end of the hold, not to decide when
+ /// the hold ends — see `isWithinDimmedMapHold` for why that distinction is
+ /// load-bearing.
+ @State private var dimmedMapHoldTask: Task?
+
+ /// Whether the dim is recent enough that the wearer is plausibly still
+ /// reading the map.
+ ///
+ /// **A missing timestamp means hold — but only if this page has been seen at
+ /// full luminance.** `dimmedAt` is written by `onChange`, which runs *after*
+ /// the body evaluation that first sees reduced luminance. Defaulting to "not
+ /// holding" would therefore give that first frame `showsMap == false` and
+ /// only restore the map on the update the callback provokes — turning the
+ /// flicker this fixes into a teardown and a full rebuild, complete with a
+ /// fresh camera anchor, at the exact moment the wearer is looking at it.
+ ///
+ /// `hasBeenBright` is what keeps that default from swallowing the Always-On
+ /// design. A page that *appeared* already dimmed was never mid-glance, and
+ /// without this it opened straight onto MapKit — measured, not theorised: a
+ /// force-dimmed cold launch built the map subtree until this guard existed.
+ ///
+ /// **Past that, read the clock rather than a flag the expiry task cleared.**
+ /// watchOS suspends this app within about a second of the dim — measured by
+ /// the removed hitch detector, which lost 7.42 s of an 8.20 s wrist-down,
+ /// 12.89 of 13.63, 23.01 of 23.99 — so `dimmedMapHoldTask` cannot be relied
+ /// on to fire on time, and a boolean it owns would sit `true` for a whole
+ /// wrist-down. Evaluating the timestamp means every render that actually
+ /// happens gets the right answer, whenever it happens.
+ ///
+ /// That still needs a render to happen at all, which is what
+ /// `dimmedMapHoldTicker` is for.
+ /// Whether a dim arriving right now could hold anything.
+ ///
+ /// The same conditions `isWithinDimmedMapHold` applies, minus the ones that
+ /// only make sense once a hold exists. Kept separate so arming can be
+ /// skipped entirely rather than armed and then rejected on every render.
+ private var canHoldMapThroughDim: Bool {
+ settings.mainPageContent == .map && isSelected && hasBeenBright
+ }
+
+ private var isWithinDimmedMapHold: Bool {
+ guard isSelected, isLuminanceReduced, hasBeenBright, !isDimmedMapHoldExpired
+ else {
+ return false
+ }
+ guard let dimmedAt else { return true }
+ return Date().timeIntervalSince(dimmedAt) < Self.dimmedMapHold
+ }
+
+ /// Latched by the expiry task or the ticker, so a hold that has ended cannot
+ /// be revived by `dimmedAt` being cleared out from under it.
+ @State private var isDimmedMapHoldExpired = false
+
+ /// Whether this page has been *watched* — selected and at full luminance —
+ /// at some point since its state was created.
+ ///
+ /// Only then can a dim be part of a glance rather than the state the page was
+ /// born into. It is deliberately not cleared by the view lifecycle: doing so
+ /// broke the feature on hardware, because watchOS re-hosts the page at the
+ /// dim and the reset landed mid-glance. Fresh `@State` already covers the
+ /// case it was meant to, since a page SwiftUI genuinely rebuilds starts
+ /// `false`.
+ ///
+ /// What it guards is narrower than an earlier comment here claimed. An
+ /// off-screen page is already excluded — `isWithinDimmedMapHold` checks
+ /// `isSelected` directly. This covers the two cases that check cannot see: a
+ /// page that appeared already dimmed, and one selected for the first time
+ /// while dimmed. Neither is a glance in progress.
+ @State private var hasBeenBright = false
+
+ @State private var camera: MapCameraPosition = .automatic
+
+ /// Where the puck is *drawn*, as opposed to where the phone says it is.
+ ///
+ /// **The puck cannot read `fix` directly if the camera is ever animated.**
+ /// `fix` is derived from `client.snapshot`, which the transport replaces in
+ /// its own transaction, so by the time this page reacts the annotation has
+ /// already moved. Easing the camera afterwards would leave the puck sliding
+ /// away from centre and snapping back — the wander that `recenterIfFollowing`
+ /// cuts to avoid. Holding the rendered position in `@State` lets both change
+ /// inside one `withAnimation`, so they travel on the same curve and the puck
+ /// stays visually still while the world moves beneath it.
+ ///
+ /// Scoped to the native map's lifetime like `hasAssertedRegion`: `mapContent`
+ /// seeds it on appear, so a rebuilt map opens on the truth rather than
+ /// animating in from wherever the last glance ended.
+ @State private var displayedFix: CLLocationCoordinate2D?
+
+ /// Non-nil once we have driven the camera. Assignment is not proof that
+ /// MapKit rendered the request, but it is the first half of the span
+ /// handshake that keeps `.automatic` from becoming the remembered zoom.
+ ///
+ /// It doubles as the in-memory half of the centre seed: it is by definition
+ /// the last place we drove the camera to, so a rebuilt map can be restored
+ /// from it without waiting on a fix.
+ @State private var programmaticCenter: CLLocationCoordinate2D?
+
+ /// Whether *this* native map has been given a region of ours.
+ ///
+ /// Scoped to the map's lifetime like `hasConfirmedRequestedSpan`, and for the
+ /// same reason: `MapPage` survives a wrist drop while the `Map` inside it does
+ /// not, so a flag scoped to the page would claim a freshly built map was
+ /// already anchored. Cleared in `mapContent`'s `onAppear`.
+ ///
+ /// This is what makes the anchor idempotent. Follow re-asserts on every geo
+ /// update and that is fine; the anchor must fire exactly once per map, or a
+ /// wearer with Follow off would have their Crown zoom overwritten by a
+ /// re-assert on every panel resize.
+ @State private var hasAssertedRegion = false
+
+ /// Span counterpart to the centre handshake: MapKit can report its old
+ /// `.automatic` fit after we assign a region, so assignment alone is not
+ /// evidence that a rendered zoom came from us.
+ ///
+ /// Scoped to the *native map's* lifetime, not the page's. Dimming tears the
+ /// map subtree down while `MapPage` — and therefore this `@State` — survives,
+ /// so a confirmation earned before a wrist drop would otherwise still be
+ /// standing when a freshly built `Map` emits its first region. Nothing proves
+ /// that region is ours, and with the handshake already satisfied
+ /// `noteRenderedRegion` would bank it as a Crown zoom and overwrite the
+ /// wearer's saved span. `mapContent` clears it on appear so every native map
+ /// re-earns the confirmation.
+ ///
+ /// **Load-bearing, and measured so.** The simulator gave no support for this
+ /// at all — three teardown cycles, 0.0% drift every time — and it was written
+ /// as speculative hardening. Hardware disagreed immediately. Once the camera
+ /// falls into `.automatic` it reports a continental fit on *every* subsequent
+ /// rebuild: twenty consecutive callbacks at 34.364390 degrees, ~3,800 km,
+ /// against a requested 0.000500. Each one arrives at a freshly built map with
+ /// this flag cleared, so the 25% gate rejects it and the wearer's saved zoom
+ /// survives a completely broken camera. Without the reset, the first of those
+ /// twenty would have been banked as a Crown zoom and persisted.
+ ///
+ /// This protects the *setting*, not the view. The camera getting stuck is a
+ /// separate defect — see `recenterIfFollowing`.
+ @State private var hasConfirmedRequestedSpan = false
+
+ /// Initial confirmation only needs to distinguish our request from the much
+ /// wider automatic annotation fit. MapKit may adjust a requested region for
+ /// display geometry, so a generous tolerance avoids quietly disabling Crown
+ /// persistence by waiting forever for an exact span.
+ private static let spanConfirmationTolerance = 0.25
+
+ private var snapshot: WatchSnapshot? { client.snapshot }
+
+ private var fix: CLLocationCoordinate2D? {
+ guard let you = snapshot?.geo.you else { return nil }
+ return CLLocationCoordinate2D(latitude: you.lat, longitude: you.lon)
+ }
+
+ private var isFollowing: Bool {
+ settings.follow
+ }
+
+ @State private var showingNodes = false
+ @State private var armedToolbarControl: TrailingToolbarControl?
+ @State private var disarmToolbarTask: Task?
+
+ private enum TrailingToolbarControl: Equatable {
+ case start
+ case stop
+ case ping
+
+ var command: WatchCommand.Kind {
+ switch self {
+ case .start: return .startSession
+ case .stop: return .stopSession
+ case .ping: return .manualPing
+ }
+ }
+ }
+
+ #if DEBUG
+ /// When the phase last changed, opening a short window in which the camera
+ /// probes below are allowed to speak.
+ ///
+ /// **The volume is the point.** Adam sees the map jump at the end of the wait
+ /// timer and again at the end of the listening timer, and three things happen
+ /// at exactly those moments: a new fix can arrive and recentre, the status
+ /// panel's height can change and recentre through `PanelHeightKey`, or the
+ /// subtree can be rebuilt. A probe on every camera callback would drown that
+ /// in follow updates — the mistake this file's wake logging made once
+ /// already, at roughly 400 file writes an hour against the thing being
+ /// measured. Logging only around a boundary keeps a walk's worth of evidence
+ /// down to a handful of lines per cycle.
+ @State private var phaseBoundaryAt: Date?
+
+ /// The phase as of the last boundary this page logged.
+ @State private var boundaryPhase: String?
+
+ private static let phaseBoundaryWindow: TimeInterval = 2.5
+
+ /// SwiftUI does not promise an order between two `onChange` handlers on the
+ /// same view, so a window that only opened when the phase probe happened to
+ /// run first would miss exactly the callbacks worth catching. A phase that
+ /// has not been recorded yet counts as inside it.
+ private var isNearPhaseBoundary: Bool {
+ if snapshot?.phase != boundaryPhase { return true }
+ guard let phaseBoundaryAt else { return false }
+ return Date().timeIntervalSince(phaseBoundaryAt) < Self.phaseBoundaryWindow
+ }
+ #endif
+
+ /// The panel's measured height, which is what the camera inset needs and,
+ /// unlike its position, does not change while a page transition is animating.
+ @State private var panelHeight: CGFloat = 0
+
+ /// The panel height the **camera** frames against, which is not always the
+ /// height just measured.
+ ///
+ /// **The panel thrashes when a session starts or stops, and every value it
+ /// passes through used to be a camera reframe.** Measured on the wrist:
+ /// 54 -> 59 -> 40 -> 54 inside three seconds across
+ /// `skipped -> idle -> starting -> listening_discovery`, four `applyRegion`
+ /// assignments, none animated. Ordinary phase boundaries did *not* move the
+ /// panel — it held at 54.0 through four of them — so this is a session-lifecycle
+ /// event, not a per-cycle one.
+ ///
+ /// **The inset changes apparent zoom, not just the centre.** `panelCameraInset`
+ /// feeds `.safeAreaPadding(.bottom,)`, and `applyRegion` holds the span
+ /// constant, so a taller panel squeezes the same span into fewer points and
+ /// the map reads as zooming *in*. Adam on the first asymmetric attempt: *"when
+ /// I click stop it looks like it zooms in and out slightly"* — a grow adopted
+ /// at once, then a shrink adopted 450 ms later, each one a visible scale
+ /// change. Hence one settled adoption for both directions: the wearer should
+ /// see the map adjust once, not twice.
+ @State private var cameraPanelHeight: CGFloat = 0
+ @State private var panelSettleTask: Task?
+
+ /// The tallest the panel has been this launch, and what the camera actually
+ /// frames against.
+ ///
+ /// **Adam's simplification, and it is the right one: treat the panel as though
+ /// it were always at its maximum.** A settled, animated reframe was still one
+ /// visible zoom step per session transition, because a panel that populates to
+ /// two rows and later loses them is a real change in what MapKit is asked to
+ /// fit. Framing against the high-water mark means a *shrink* moves the camera
+ /// not at all — the common case, and the one Adam saw — while growth still
+ /// gets the settle and the ease below.
+ ///
+ /// **The cost, stated plainly:** while the panel is shorter than its maximum
+ /// the map is framed as if it were not, so the puck sits a few points above
+ /// the true centre of the visible band. Half the difference in heights, so
+ /// under ~10 pt at the sizes measured. A puck slightly off centre that never
+ /// moves reads far better than a centred one that jumps, which is the whole
+ /// trade being made here.
+ ///
+ /// Per launch rather than persisted: `@State` on the page survives the map
+ /// subtree's teardown on a wrist raise, so the mark holds across glances, and
+ /// a fresh launch re-derives it rather than inheriting a stale measurement
+ /// from a type ladder or content shape that has since changed. The 0.75-of-
+ /// display clamp in `panelCameraInset` still bounds it.
+ @State private var panelHighWaterMark: CGFloat = 0
+
+ /// Long enough to swallow a session transition's churn, short enough that the
+ /// map does not sit visibly mis-framed while it waits.
+ private static let panelSettleDelay: Duration = .milliseconds(450)
+ @State private var latchedTopSafeAreaInset: CGFloat = 0
+ @State private var currentTopSafeAreaInset: CGFloat = 0
+ @State private var bottomSafeAreaInset: CGFloat = 0
+
+ /// Only one subtree sets this, but every *other* subtree still contributes
+ /// the default. Taking `nextValue()` unconditionally would let a later
+ /// sibling's `.zero` overwrite the real measurement, so empties are ignored.
+ ///
+ /// This carries the panel's **height**, never its position. Position was a
+ /// runaway loop: an interactive page swipe translates the page, so a global
+ /// frame changes every frame, which changed the camera inset, which
+ /// re-framed the map, which invalidated layout, which re-measured. The watch
+ /// wedged with a blurred half-finished transition — 21,611 MapKit
+ /// reconfigurations and 4,300 body evaluations per second, main thread never
+ /// free. Height does not change when the page moves, so the cycle cannot
+ /// close. A programmatic selection change never exposes those intermediate
+ /// positions, which is why only a real swipe reproduced it.
+ private struct PanelHeightKey: PreferenceKey {
+ static let defaultValue: CGFloat = 0
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
+ let next = nextValue()
+ if next > 0 { value = next }
+ }
+ }
+
+ /// How much of the display the status panel is covering, handed to MapKit so
+ /// it frames the camera in the band that remains visible.
+ ///
+ /// Derived from the panel's height plus the gap it leaves beneath itself,
+ /// which reconstructs exactly what a bottom-anchored panel occupies without
+ /// asking where it currently is. Measured rather than assumed because the
+ /// height changes with content — one column or two, with or without heard
+ /// rows — and a stale constant would drift the fix off centre precisely when
+ /// the panel grew.
+ /// Reads `cameraPanelHeight`, never the raw measurement, so a boundary's
+ /// transient heights never reach MapKit.
+ private var panelCameraInset: CGFloat {
+ guard cameraPanelHeight > 0 else { return 0 }
+ let gapBeneath = curvedPanelHorizontalInset == nil
+ ? bottomSafeAreaInset
+ : panelBottomGap
+ // Clamped: an inset approaching the display height would leave MapKit no
+ // band to frame, and nothing about a status panel justifies that.
+ let limit = WKInterfaceDevice.current().screenBounds.height * 0.75
+ return min(max(0, cameraPanelHeight + gapBeneath), limit)
+ }
+
+ /// Lets the measured height stop moving before the camera follows it, in both
+ /// directions, and animates the single reframe that results.
+ ///
+ /// **One adoption, not one per measurement.** In the measured
+ /// 54 -> 59 -> 40 -> 54 swing the camera is assigned once, at 54 — which is
+ /// where it started, so the whole session transition costs *no* reframe at
+ /// all. The intermediate 59 and the 19 pt collapse to 40 never reach MapKit.
+ /// An earlier attempt adopted growth immediately to guarantee the panel could
+ /// never cover the puck; that produced two visible scale changes 450 ms apart
+ /// and the protection was unnecessary at real panel sizes, where the puck sits
+ /// roughly 85 pt above a panel top near 170.
+ ///
+ /// **Animated, because a settled change is a rare and deliberate one.** It
+ /// happens on a session start or stop, not every cycle, so there is nothing
+ /// for the 0.25 s ease to compete with — the objection to animating was four
+ /// overlapping reframes, and there is now at most one.
+ ///
+ /// **Deliberately fail-safe rather than timer-dependent.** watchOS suspends
+ /// this app, so the sleep below fires whenever it is next resumed rather than
+ /// on schedule — the trap the dim hold was bitten by twice. A late fire only
+ /// reframes late. Against a fire that never comes, the map subtree's
+ /// `onAppear` reconciles the two heights outright, so no wrist raise can find
+ /// the camera framing against a height the panel abandoned.
+ private func adoptPanelHeightForCamera() {
+ panelHighWaterMark = max(panelHighWaterMark, panelHeight)
+ panelSettleTask?.cancel()
+ panelSettleTask = nil
+ // A shrink cannot change the mark, so it lands here and stops — no task, no
+ // reframe, nothing for the wearer to see. That is the point.
+ guard cameraPanelHeight != panelHighWaterMark else { return }
+
+ panelSettleTask = Task { @MainActor in
+ try? await Task.sleep(for: Self.panelSettleDelay)
+ guard !Task.isCancelled, panelHighWaterMark != cameraPanelHeight else { return }
+ #if DEBUG
+ WakeLog.note(
+ String(
+ format: "panel-settled %.1f -> %.1f (measured %.1f)",
+ cameraPanelHeight, panelHighWaterMark, panelHeight
+ )
+ )
+ #endif
+ cameraPanelHeight = panelHighWaterMark
+ panelSettleTask = nil
+ recenterIfFollowing(animated: true)
+ }
+ }
+
+ /// Drops any pending settle and takes the measured height as it stands.
+ ///
+ /// The safety net for the suspension case above, and the right behaviour on an
+ /// appearance regardless: a subtree that is being built has no in-flight
+ /// framing for a wearer to be watching, so there is nothing to animate and
+ /// nothing to smooth over.
+ private func adoptPanelHeightNow() {
+ panelHighWaterMark = max(panelHighWaterMark, panelHeight)
+ panelSettleTask?.cancel()
+ panelSettleTask = nil
+ guard cameraPanelHeight != panelHighWaterMark else { return }
+ cameraPanelHeight = panelHighWaterMark
+ recenterIfFollowing()
+ }
+
+ /// The placement scales with the estimated corner radius, putting every
+ /// watch at the same relative position on its curve. Ten points at R=19 is a
+ /// modest lift from the hardware-approved eight; unlike the earlier
+ /// deliberately narrow treatment, clearance is now evaluated at that real
+ /// position so the lift buys the glanceable width the wearer requested.
+ private static let panelBottomGapRatio: CGFloat = 10.0 / 19.0
+ private static let readoutFailureBannerBottomGap: CGFloat = 3
+
+ private var panelBottomGap: CGFloat {
+ bottomSafeAreaInset * Self.panelBottomGapRatio
+ }
+
+ /// Panel geometry is about the physical display, not this view's proposal.
+ /// Reading the device avoids a feedback loop where panel padding changes the
+ /// container width that is then used to recompute that same padding.
+ private var screenWidth: CGFloat { WKInterfaceDevice.current().screenBounds.width }
+
+ /// Horizontal clearance that lets the panel descend into the bottom safe
+ /// area without putting its corners outside the curved display.
+ ///
+ /// watchOS exposes no screen corner radius, but its bottom safe-area inset is
+ /// the clearance a full-width element needs at zero horizontal inset, making
+ /// it a useful estimate of that radius. The circle/chord intersection gives
+ /// the inset at the panel's actual placement gap. That radius estimate is a
+ /// lower bound on the glass curvature, so four extra points are cheap
+ /// insurance against another hardware clip. The rectangle test is
+ /// conservative in the other direction: the panel's own 12 pt radius pulls
+ /// its visible corners inward from the square corners protected here.
+ private var curvedPanelHorizontalInset: CGFloat? {
+ let radius = bottomSafeAreaInset
+ let gap = panelBottomGap
+ guard radius > 0, gap < radius else { return nil }
+ let inset = radius - sqrt(max(0, 2 * radius * gap - gap * gap)) + 4
+ guard inset.isFinite else { return nil }
+ return inset
+ }
+
+ /// A missing safe-area measurement is not permission to draw to the edge:
+ /// retaining the old four-point inset and safe-area placement is the only
+ /// fallback that is known not to clip on hardware.
+ private var panelHorizontalInset: CGFloat { curvedPanelHorizontalInset ?? 4 }
+
+ /// Width left after the curvature clearance and the panel's own 8 pt inset
+ /// on each side. Row sizing uses this same budget as the rendered content,
+ /// so its two-column decision does not depend on a nominal watch size.
+ private var panelContentWidth: CGFloat {
+ max(0, screenWidth - 2 * panelHorizontalInset - 16)
+ }
+
+ /// A centred toast with one radius of margin on each side is entirely
+ /// inboard of the bottom curve, so its vertical position no longer needs the
+ /// panel's chord calculation. Absence remains distinct from zero: without a
+ /// measured radius the banner must stay in the safe area.
+ private var readoutFailureBannerMaxWidth: CGFloat? {
+ guard bottomSafeAreaInset > 0 else { return nil }
+ let width = screenWidth - 2 * bottomSafeAreaInset
+ guard width.isFinite, width > 0 else { return nil }
+ return width
+ }
+
+ var body: some View {
+ pageContent
+ // **Both items stay in the toolbar at all times.** They used to be wrapped
+ // in `if !isLuminanceReduced`, and removing them at the dim changes the
+ // toolbar's structure, which re-hosts this page and rebuilds the entire
+ // MapKit subtree underneath it. Measured on a Series 9: a
+ // `map-subtree-appeared` 0.13 s after every single `hold-armed`, with the
+ // old subtree discarded 0.09 s later — a full teardown, fresh camera
+ // anchor and basemap repaint, right under the wearer's eye. Adam saw it
+ // as the map jumping as the display faded. With the items always present
+ // the dim rebuilds nothing at all.
+ //
+ // Hidden by value rather than by presence, and hidden *properly*:
+ // `.opacity(0)` alone leaves a live button, and `.allowsHitTesting`
+ // covers ordinary touch but neither disables the action nor takes it out
+ // of the accessibility tree. Start transmits on a single tap with no
+ // confirmation, so a VoiceOver focus carried across the dim could fire
+ // it on an invisible control. `hiddenWhileDimmed` disables and
+ // accessibility-hides as well; all three are value changes, so the
+ // toolbar's structure stays fixed.
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ mainPageToggle.hiddenWhileDimmed(isLuminanceReduced)
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ trailingToolbarButton.hiddenWhileDimmed(isLuminanceReduced)
+ }
+ }
+ .background(
+ GeometryReader { geo in
+ Color.clear
+ .onAppear {
+ noteTopSafeAreaInset(geo.safeAreaInsets.top)
+ latchBottomSafeAreaInset(geo.safeAreaInsets.bottom)
+ }
+ .onChange(of: geo.safeAreaInsets.top) { _, inset in
+ noteTopSafeAreaInset(inset)
+ }
+ .onChange(of: geo.safeAreaInsets.bottom) { _, inset in
+ latchBottomSafeAreaInset(inset)
+ }
+ .onChange(of: isLuminanceReduced) { _, reduced in
+ // If the view first appeared while dimmed, returning to full
+ // luminance on the readout is the first valid opportunity to
+ // establish the reference even when the inset did not change.
+ if !reduced && !showsMap {
+ noteTopSafeAreaInset(geo.safeAreaInsets.top)
+ }
+ }
+ .onChange(of: showsMap) { _, mapIsShowing in
+ // Switching from the map supplies a readout reference even if
+ // both surfaces initially report the same transient inset.
+ if !mapIsShowing && !isLuminanceReduced {
+ noteTopSafeAreaInset(geo.safeAreaInsets.top)
+ }
+ }
+ }
+ // Plain is intentional: `.ignoresSafeArea()` makes this reader report
+ // the insets of its own expanded region, which are zero. The bottom's
+ // first nonzero value is latched before its dependent panel geometry can
+ // feed back into layout; the top follows the separate visual-only rule
+ // in `noteTopSafeAreaInset`.
+ )
+ .sheet(isPresented: $showingNodes) {
+ NavigationStack {
+ NodeListView()
+ .navigationTitle("Heard")
+ .navigationBarTitleDisplayMode(.inline)
+ }
+ }
+ .onAppear {
+ #if DEBUG
+ // Lets the sheet layout be captured and iterated on headlessly; the
+ // simulator has no way to tap the bar.
+ if UserDefaults.standard.bool(forKey: "MeshMapperShowNodeSheet") {
+ showingNodes = true
+ }
+ // A refusal normally needs a real command round-trip, which makes this
+ // transient impossible to capture headlessly. It still expires through
+ // the production six-second path, so screenshots must be prompt.
+ if let forced = UserDefaults.standard.string(forKey: "MeshMapperForceRefusal") {
+ client.debugForceRefusal(forced)
+ }
+ // Time the readout->map switch, which rebuilds the same MapKit tree a
+ // wrist raise does — the one thing about a raise that is reproducible
+ // without a wrist, since Always-On cannot be entered here. Pair it with
+ // `-layout.mainPageContent readout` or the flip is a no-op against a
+ // map that is already showing, which reads as a suspiciously fast
+ // result rather than as no measurement at all.
+ let autoDim = UserDefaults.standard.integer(forKey: "MeshMapperAutoDimAfter")
+ if autoDim > 0 {
+ Task { @MainActor in
+ try? await Task.sleep(for: .seconds(autoDim))
+ WakeLog.note("debug-forcing-dim")
+ debugForcedDim = true
+ }
+ }
+ if UserDefaults.standard.bool(forKey: "MeshMapperTimeSwitchToMap") {
+ Task { @MainActor in
+ try? await Task.sleep(for: .seconds(5))
+ NSLog("[switch] requesting map at %f", Date().timeIntervalSince1970)
+ settings.mainPageContent = .map
+ }
+ }
+ #endif
+ client.setMapGeoNeeded(needsMapGeo)
+ // A page that opens selected and bright is a glance in progress; one
+ // that opens dimmed or behind another page is not, and must not hold a
+ // map it never showed.
+ armGlanceIfWatched()
+ }
+ .onChange(of: needsMapGeo) { _, needed in
+ client.setMapGeoNeeded(needed)
+ }
+ .onChange(of: isSelected) { _, selected in
+ // A sheet belongs to the page that raised it. Leaving one presented
+ // while the pager moves on strands a modal over a different page,
+ // where it blurs that page and swallows every swipe and Crown turn —
+ // the app looks crashed while it is merely holding a stuck sheet.
+ if !selected { showingNodes = false }
+ noteSelection(selected)
+ }
+ .onChange(of: trailingToolbarControl) { _, _ in
+ // Stable facts own the slot, but a session transition still changes
+ // its meaning. Never carry an armed confirmation into a new action.
+ disarmToolbarControl()
+ }
+ .onChange(of: trailingToolbarControlIsEnabled) { _, enabled in
+ if !enabled { disarmToolbarControl() }
+ }
+ .onChange(of: isLuminanceReduced) { _, reduced in
+ noteLuminance(reduced: reduced)
+ if reduced { disarmToolbarControl() }
+ #if DEBUG
+ // The reference timestamp for a wrist-raise measurement. Nothing else
+ // marks the moment the wrist came up, so without this the `map` and
+ // `span` lines have no zero to be measured against.
+ WakeLog.note(reduced ? "wrist-down" : "wrist-up")
+ #endif
+ }
+ // Deliberately does NOT touch the hold. Apple's guidance is that Always On
+ // keeps views in the hierarchy, but hardware also collapses the
+ // navigation bar at the dim where the simulator does not — and the
+ // re-host that causes fired this handler mid-glance, which destroyed the
+ // hold about a second in and put the wearer straight back on the readout.
+ // `@State` already gives the right answer for free: if SwiftUI really
+ // destroys this page, `hasBeenBright` goes with it and a page rebuilt
+ // while dimmed starts unarmed. If it survives, so should the glance.
+ .onDisappear { disarmToolbarControl() }
+ // A main-actor "hitch" detector lived here — a 250 ms tick that logged
+ // whenever it lost more than a second — meant to decide whether the 6.1 s
+ // wake stall was the app being descheduled or SwiftUI deferring the
+ // rebuild. It was removed because it cannot tell those apart from the one
+ // thing that always happens: watchOS suspends this app while the wrist is
+ // down, so the tick simply stops. Measured, it fired on every single wake
+ // at almost exactly the wrist-down duration — 7.42 s against 8.20 s,
+ // 12.89 against 13.63, 23.01 against 23.99. It reports suspension and
+ // calls it a stall.
+ //
+ // Its silence was equally worthless, and had already been cited as
+ // evidence that the stall "did not recur". Anything replacing it must
+ // establish the app was actually scheduled — a suspension-aware signal
+ // such as `scenePhase`, not a timer that cannot observe its own absence.
+ }
+
+ private var pageContent: some View {
+ ZStack {
+ if showsMap {
+ // Keep the map inside this branch: constructing even map
+ // infrastructure behind the readout would defeat its battery purpose.
+ //
+ // There is deliberately no `MapReader` here. One wrapped this content
+ // while `centerPlacing` and `correctPlacement` translated the camera
+ // through `proxy.convert`; camera placement is now `.safeAreaPadding`,
+ // which insets MapKit's own framing, so no coordinate conversion
+ // happens anywhere in this file. The proxy was threaded through six
+ // functions unread. Do not reintroduce a reader to place the camera —
+ // see the handoff: the map's SwiftUI frame is not the map, and any
+ // offset computed from view geometry under-shoots.
+ mapContent
+ } else {
+ readoutContent
+ }
+ }
+ // Attached as a background rather than as a sibling in the ZStack. As a
+ // sibling it enters the stack exactly when luminance drops, which
+ // re-identifies the branch beside it and rebuilt the whole MapKit subtree
+ // at the dim — measured on hardware as `map-subtree-appeared` 0.13 s after
+ // every `hold-armed`, and visible on the wrist as the map jumping right
+ // as it dims. A background cannot change the identity of the content it
+ // decorates.
+ .background(dimmedMapHoldTicker)
+ }
+
+ /// Asks watchOS to render this page again when the hold ends.
+ ///
+ /// **Without this the hold could not end during a real wrist-down at all.**
+ /// The clock in `isWithinDimmedMapHold` only decides the answer for renders
+ /// that happen, and a suspended app renders nothing: the last frame drawn
+ /// stays on the Always-On display until something asks for another. Hold the
+ /// map and then suspend, and the map — not the approved readout — would be
+ /// the Always-On surface for the whole time the wrist was down.
+ ///
+ /// A timeline entry is the supported way to ask for that render — the same
+ /// mechanism the readout's coarse countdown uses while dimmed, though not
+ /// literally the same wake, since that countdown is not in the tree while the
+ /// map is held. watchOS may still coalesce them into one cadence. The
+ /// schedule repeats rather than naming the single boundary: a two-entry
+ /// `.explicit` schedule can be exhausted by an early re-query and then never
+ /// ask for another render at all, which would strand the map on the display.
+ /// Repeating entries cannot run out, and the ticker leaves the tree entirely
+ /// once the hold is expired, so nothing keeps asking.
+ ///
+ /// Always-On throttles these updates to roughly one a minute, so the readout
+ /// may return well after the twelve seconds `dimmedMapHold` asks for — and
+ /// the shorter the hold, the larger that overshoot is in proportion. Nobody
+ /// is looking by then; the point is that it returns without needing the
+ /// wrist. The scrim on `map` is what bounds the cost of the overshoot, since
+ /// the basemap is dimmed for every second of it.
+ @ViewBuilder
+ private var dimmedMapHoldTicker: some View {
+ if isLuminanceReduced, !isDimmedMapHoldExpired, let dimmedAt {
+ TimelineView(.periodic(from: dimmedAt, by: Self.dimmedMapHold)) { context in
+ Color.clear
+ .frame(width: 0, height: 0)
+ .onChange(of: context.date) { _, _ in
+ // **A schedule entry arriving is not proof its deadline arrived.**
+ // Entering Always On changes the timeline's cadence, which
+ // re-queries the schedule, and SwiftUI can hand over the next entry
+ // there and then. An earlier version treated the date changing as
+ // the boundary and called this directly: on a Series 9 it expired
+ // the hold 0.82, 0.85, 0.94 and 1.05 s after the dim, four glances
+ // out of four, while the simulator — which never enters Always On,
+ // so never changes cadence — passed every time.
+ //
+ // The clock is the authority, exactly as it is in
+ // `isWithinDimmedMapHold`. This callback only decides whether the
+ // render that just happened is the one that ends the hold.
+ guard Date().timeIntervalSince(dimmedAt) >= Self.dimmedMapHold else {
+ return
+ }
+ expireDimmedMapHold()
+ }
+ }
+ }
+ }
+
+ private var commandFailure: String? {
+ guard !isLuminanceReduced, let refusal = client.lastRefusal else {
+ return nil
+ }
+ switch client.lastRefusalCommand {
+ case nil, .startSession, .stopSession, .manualPing:
+ return refusal
+ case .requestSnapshot:
+ return nil
+ }
+ }
+
+ @ViewBuilder
+ private var commandFailureBanner: some View {
+ failureBanner(fillsAvailableWidth: true)
+ }
+
+ @ViewBuilder
+ private var compactCommandFailureBanner: some View {
+ failureBanner(fillsAvailableWidth: false)
+ }
+
+ @ViewBuilder
+ private func failureBanner(fillsAvailableWidth: Bool) -> some View {
+ if let commandFailure {
+ HStack(alignment: .firstTextBaseline, spacing: 4) {
+ Image(systemName: "exclamationmark.circle.fill")
+ Text(commandFailure)
+ .lineLimit(2)
+ }
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(WatchPalette.armed)
+ .multilineTextAlignment(.leading)
+ .frame(
+ maxWidth: fillsAvailableWidth ? .infinity : nil,
+ alignment: .leading
+ )
+ .padding(.horizontal, 7)
+ .padding(.vertical, 4)
+ .background(
+ .ultraThinMaterial,
+ in: .rect(cornerRadius: WatchPalette.cornerRadius, style: .continuous)
+ )
+ .overlay(
+ RoundedRectangle(
+ cornerRadius: WatchPalette.cornerRadius,
+ style: .continuous
+ )
+ .stroke(.white.opacity(0.12), lineWidth: 0.5)
+ )
+ // Placement belongs to each surface's overlay. The banner itself owns no
+ // layout lifetime or geometry; the client still expires it after six
+ // seconds, and reduced luminance suppresses it in `commandFailure`.
+ .allowsHitTesting(false)
+ }
+ }
+
+ private var mainPageToggle: some View {
+ Button {
+ settings.mainPageContent = settings.mainPageContent == .map
+ ? .readout
+ : .map
+ } label: {
+ // The glyph names the destination, following the convention for a
+ // two-state corner control. The framed list survives toolbar scaling on
+ // 40 mm better than bare bullets and still reads as the full readout.
+ Image(systemName: settings.mainPageContent == .map
+ ? "list.bullet.rectangle"
+ : "map.fill")
+ }
+ .accessibilityLabel(settings.mainPageContent == .map
+ ? "Show readout"
+ : "Show map")
+ }
+
+ /// Slot identity follows stable session and regional facts, never the live
+ /// ping gate. A cooldown may disable Ping, but cannot replace it with Stop
+ /// while a finger is already moving toward the corner.
+ private var trailingToolbarControl: TrailingToolbarControl {
+ guard client.snapshot?.controls.isSessionActive == true else { return .start }
+ if settings.showPingWhenAvailable,
+ client.snapshot?.controls.manualPingApplicable == true
+ {
+ return .ping
+ }
+ return .stop
+ }
+
+ private var trailingToolbarControlIsEnabled: Bool {
+ guard client.pendingCommand != trailingToolbarControl.command else { return false }
+ switch trailingToolbarControl {
+ case .start, .stop:
+ return client.snapshot?.controls.canStartStop == true
+ case .ping:
+ return client.snapshot?.controls.canManualPing == true
+ }
+ }
+
+ private var effectiveStartMode: WatchSettings.DefaultStartMode {
+ settings.effectiveStartMode(
+ availableStartModes: client.snapshot?.availableStartModes
+ )
+ }
+
+ @ViewBuilder
+ private var trailingToolbarButton: some View {
+ let control = trailingToolbarControl
+ let pending = client.pendingCommand == control.command
+ let armed = armedToolbarControl == control
+
+ let button = Button {
+ handleToolbarControl(control)
+ } label: {
+ ZStack {
+ if pending {
+ ProgressView()
+ .controlSize(.mini)
+ .tint(toolbarActionColor(for: control))
+ } else {
+ Image(systemName: toolbarIcon(for: control, armed: armed))
+ .foregroundStyle(toolbarGlyphColor(for: control, armed: armed))
+ }
+ }
+ .frame(width: 18, height: 18)
+ }
+ .disabled(!trailingToolbarControlIsEnabled)
+ .accessibilityLabel(toolbarAccessibilityLabel(for: control, armed: armed))
+
+ if armed {
+ // Resting actions use the same quiet glass container as the display
+ // toggle. The three-second confirmation window is deliberately the only
+ // filled state: its amber capsule signals that the next tap has a
+ // consequence, rather than merely decorating a persistent control.
+ button.tint(WatchPalette.armed)
+ } else {
+ button
+ }
+ }
+
+ private func handleToolbarControl(_ control: TrailingToolbarControl) {
+ switch control {
+ case .start:
+ disarmToolbarControl()
+ client.send(.startSession, mode: effectiveStartMode.rawValue)
+ case .stop, .ping:
+ guard armedToolbarControl == control else {
+ armToolbarControl(control)
+ return
+ }
+ disarmToolbarControl()
+ client.send(control.command)
+ }
+ }
+
+ private func toolbarIcon(
+ for control: TrailingToolbarControl,
+ armed: Bool
+ ) -> String {
+ // With no usable connection, a dim play symbol falsely suggests that the
+ // empty-looking glass control is a Start affordance. Name the unavailable
+ // prerequisite instead; enablement remains entirely phone-owned.
+ if snapshot?.isConnected != true {
+ return "antenna.radiowaves.left.and.right.slash"
+ }
+ if armed { return "checkmark" }
+ switch control {
+ case .start: return "play.fill"
+ case .stop: return "stop.fill"
+ case .ping: return "dot.radiowaves.left.and.right"
+ }
+ }
+
+ private func toolbarActionColor(
+ for control: TrailingToolbarControl
+ ) -> Color {
+ switch control {
+ case .start: return WatchPalette.start
+ case .stop: return WatchPalette.stop
+ case .ping: return WatchPalette.ping
+ }
+ }
+
+ private func toolbarGlyphColor(
+ for control: TrailingToolbarControl,
+ armed: Bool
+ ) -> Color {
+ // The darker disabled slate vanished against Apple's darkest basemap.
+ // System disabled dimming still distinguishes this brighter slate from an
+ // enabled action without making the glass circle look empty.
+ guard trailingToolbarControlIsEnabled else { return WatchPalette.tertiary }
+ if armed { return .white }
+ return toolbarActionColor(for: control)
+ }
+
+ private func toolbarAccessibilityLabel(
+ for control: TrailingToolbarControl,
+ armed: Bool
+ ) -> String {
+ guard let snapshot else { return "Waiting for iPhone" }
+ guard snapshot.isConnected else { return "Device disconnected" }
+ switch control {
+ case .start: return "Start \(effectiveStartMode.label) session"
+ case .stop: return armed ? "Confirm stop session" : "Stop session"
+ case .ping: return armed ? "Confirm manual ping" : "Manual ping"
+ }
+ }
+
+ private func armToolbarControl(_ control: TrailingToolbarControl) {
+ disarmToolbarTask?.cancel()
+ armedToolbarControl = control
+ disarmToolbarTask = Task { @MainActor in
+ try? await Task.sleep(for: .seconds(3))
+ guard !Task.isCancelled, armedToolbarControl == control else { return }
+ armedToolbarControl = nil
+ disarmToolbarTask = nil
+ }
+ }
+
+ private func disarmToolbarControl() {
+ disarmToolbarTask?.cancel()
+ disarmToolbarTask = nil
+ armedToolbarControl = nil
+ }
+
+ private func noteLuminance(reduced: Bool) {
+ guard reduced else {
+ armGlanceIfWatched()
+ resetDimmedMapHold()
+ return
+ }
+ // Nothing to hold means nothing to arm. `isWithinDimmedMapHold` rejects
+ // all of these anyway, but arming would still start a task and put a
+ // timeline in the tree, asking watchOS for an Always-On render that can
+ // only ever decide there was no hold.
+ guard canHoldMapThroughDim else { return }
+ dimmedMapHoldTask?.cancel()
+ dimmedAt = Date()
+ isDimmedMapHoldExpired = false
+ #if DEBUG
+ WakeLog.note("hold-armed bright \(hasBeenBright) selected \(isSelected)")
+ #endif
+ // Fires only while the app happens to still be scheduled, which is roughly
+ // the first second after the dim. `dimmedMapHoldTicker` is what covers the
+ // rest; this is here so a glance that keeps the app alive ends the hold at
+ // exactly the right moment rather than at the next system update.
+ dimmedMapHoldTask = Task { @MainActor in
+ try? await Task.sleep(for: .seconds(Self.dimmedMapHold))
+ guard !Task.isCancelled else { return }
+ expireDimmedMapHold()
+ }
+ }
+
+ private func expireDimmedMapHold() {
+ dimmedMapHoldTask?.cancel()
+ dimmedMapHoldTask = nil
+ guard !isDimmedMapHoldExpired else { return }
+ isDimmedMapHoldExpired = true
+ #if DEBUG
+ WakeLog.note("hold-expired")
+ #endif
+ }
+
+ private func resetDimmedMapHold(_ reason: String = "luminance") {
+ #if DEBUG
+ if dimmedAt != nil || isDimmedMapHoldExpired {
+ WakeLog.note("hold-reset \(reason)")
+ }
+ #endif
+ dimmedMapHoldTask?.cancel()
+ dimmedMapHoldTask = nil
+ dimmedAt = nil
+ isDimmedMapHoldExpired = false
+ }
+
+ /// Ends both the hold and the glance that earned it. Only deselection does
+ /// this: it is an explicit statement that the wearer is looking elsewhere,
+ /// unlike a view-lifecycle callback, which on watchOS can fire for reasons
+ /// that have nothing to do with whether anyone is looking.
+ private func endDimmedMapHoldLifetime(_ reason: String) {
+ resetDimmedMapHold(reason)
+ #if DEBUG
+ if hasBeenBright { WakeLog.note("hold-disarmed \(reason)") }
+ #endif
+ hasBeenBright = false
+ }
+
+ private func armGlanceIfWatched() {
+ if isSelected, !isLuminanceReduced { hasBeenBright = true }
+ }
+
+ private func noteSelection(_ selected: Bool) {
+ // A hold belongs to a glance at *this* page. Swiping away ends the glance,
+ // whatever the display is doing.
+ guard selected else {
+ endDimmedMapHoldLifetime("deselected")
+ return
+ }
+ armGlanceIfWatched()
+ }
+
+ private var mapContent: some View {
+ ZStack {
+ map
+ mapOverlay
+ }
+ .onPreferenceChange(PanelHeightKey.self) { height in
+ guard abs(height - panelHeight) > 0.5 else { return }
+ #if DEBUG
+ // Panel height is a camera input: `panelCameraInset` feeds
+ // `.safeAreaPadding(.bottom,)`, so the map reframes when the panel grows
+ // or shrinks. A ping that hears nothing replaces up to four heard rows
+ // with one "Nothing heard" line, which is exactly the cycle Adam
+ // described the jump on.
+ if isNearPhaseBoundary {
+ WakeLog.note(
+ String(
+ format: "boundary panel %.1f -> %.1f camera %.1f inset %.1f",
+ panelHeight, height, cameraPanelHeight, panelCameraInset
+ )
+ )
+ }
+ #endif
+ panelHeight = height
+ // Not straight to the camera: see `adoptPanelHeightForCamera`. A
+ // `boundary panel` line with no `boundary camera` line after it is this
+ // working — the measurement moved and the map did not.
+ adoptPanelHeightForCamera()
+ }
+ .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { previous, current in
+ #if DEBUG
+ if isNearPhaseBoundary {
+ WakeLog.note(
+ "boundary fix \(previous ?? "nil") -> \(current ?? "nil") "
+ + String(format: "moved %.1f m", fixDistanceMoved(previous, current))
+ )
+ }
+ #endif
+ trackFix()
+ // A first fix is the moment a map that had nothing to anchor to becomes
+ // placeable. Idempotent, so this is a no-op on every later update.
+ anchorCameraIfNeeded()
+ }
+ .onAppear {
+ // A new native map has to re-earn the span handshake, and holds no region
+ // of ours until one is asserted below.
+ hasConfirmedRequestedSpan = false
+ hasAssertedRegion = false
+ #if DEBUG
+ // Marks the MapKit subtree being rebuilt. This is the SwiftUI half of a
+ // wrist raise only — the basemap paints later, with no callback of any
+ // kind, so the gap from here to visible pixels needs a camera or an eye.
+ WakeLog.note("map-subtree-appeared")
+ // Read as a pair with `camera-after`. The span log cannot separate "we
+ // never asserted a region" from "we asserted one and MapKit rendered its
+ // own anyway", and those want different fixes:
+ //
+ // before automatic=true, after automatic=false, span still continental
+ // -> we asserted and were ignored; the assignment is not the lever
+ // before automatic=true, after automatic=true
+ // -> `anchorCenter` was nil, so nothing was available to anchor to
+ // before automatic=false, after automatic=false, span continental
+ // -> the camera holds a region but MapKit is not honouring it
+ WakeLog.note("camera-before \(cameraStateDescription)")
+ #endif
+ // Before recentring, not after: a pending settle from a previous
+ // appearance would otherwise have this frame framed against a height the
+ // panel has already left, and `adoptPanelHeightNow` recentres itself when
+ // it actually changes anything.
+ adoptPanelHeightNow()
+ // Before the recentre, and never animated: a fresh map must open on the
+ // truth. Animating in from the previous glance's position would slide the
+ // puck across the first frame of every wrist raise.
+ displayedFix = fix
+ recenterIfFollowing()
+ anchorCameraIfNeeded()
+ #if DEBUG
+ WakeLog.note("camera-after \(cameraStateDescription)")
+ // Close the boundary window this appearance opened. `onChange` does not
+ // fire for an initial value, so without seeding it here the phase would
+ // never match and every panel measurement and camera assignment for the
+ // rest of the session would log as though it sat on a boundary.
+ boundaryPhase = snapshot?.phase
+ #endif
+ }
+ #if DEBUG
+ // Separates "built twice" from "built, torn down, rebuilt". Every device
+ // wake logs two appearances 50-70 ms apart, and the shape of the pair
+ // decides where to look: an interleaved disappear means a genuine teardown,
+ // two bare appearances mean two live instances.
+ //
+ // **The pair is settled, and it is not ours.** It is construct → construct
+ // → discard the first, netting one live subtree. A probe placed as a bare
+ // sibling above `pageContent`'s `if showsMap` doubled identically, and so
+ // did one inside `readoutContent` on a launch with no MapKit anywhere —
+ // so this is the whole page being built twice, not anything about the map.
+ // Probes on the `NavigationStack` and the `TabView` each fired once, and
+ // it survived both removing the hoisted stack entirely and making the
+ // conditional Heard page unconditional. What remains is the vertical pager
+ // building its selected page twice at launch. Nothing here can prevent it;
+ // the cost is one discarded construction against a 0.104 s rebuild.
+ .onDisappear { WakeLog.note("map-subtree-disappeared") }
+ // Opens the window the other boundary probes report inside of. Placement
+ // in the chain does not decide whether this runs before them —
+ // `isNearPhaseBoundary` treats an unrecorded transition as inside the
+ // window, so a boundary's own first callbacks are covered either way.
+ .onChange(of: snapshot?.phase) { previous, current in
+ phaseBoundaryAt = Date()
+ boundaryPhase = current
+ WakeLog.note(
+ "boundary \(previous ?? "nil") -> \(current ?? "nil") "
+ + String(
+ format: "panel %.1f camera %.1f inset %.1f ",
+ panelHeight, cameraPanelHeight, panelCameraInset
+ )
+ + cameraStateDescription
+ )
+ }
+ #endif
+ }
+
+ #if DEBUG
+ /// Distance between two `"lat,lon"` observation keys, for the boundary probe
+ /// only. The keys are this file's own formatting, so parsing them back is
+ /// cheaper than adding a second observed value beside them.
+ private func fixDistanceMoved(_ previous: String?, _ current: String?) -> Double {
+ guard let from = Self.probeCoordinate(previous),
+ let to = Self.probeCoordinate(current)
+ else { return 0 }
+ return CLLocation(latitude: from.latitude, longitude: from.longitude)
+ .distance(
+ from: CLLocation(latitude: to.latitude, longitude: to.longitude)
+ )
+ }
+
+ private static func probeCoordinate(_ key: String?) -> CLLocationCoordinate2D? {
+ let parts = key?.split(separator: ",") ?? []
+ guard parts.count == 2,
+ let lat = Double(parts[0]),
+ let lon = Double(parts[1])
+ else { return nil }
+ return CLLocationCoordinate2D(latitude: lat, longitude: lon)
+ }
+ #endif
+
+ private var readoutContent: some View {
+ ZStack {
+ // Always-On spends most of a long session with the wrist down, while a
+ // wearer may also choose this as the full-luminance main page. In both
+ // cases a black backing keeps the approved flat layout independent of
+ // whatever container presents it.
+ Color.black.ignoresSafeArea()
+ readoutStatus
+ // Hardware may collapse the navigation bar in Always-On even though
+ // the simulator's force-dimmed path cannot. Restore only an inset that
+ // disappeared relative to the full-luminance reference: equal insets
+ // produce zero, while a missing measurement is deliberately a no-op.
+ // Layout padding also consumes the height the collapsed bar returned,
+ // keeping the flexible middle spacer identical to the reference. The
+ // reader above receives its safe-area inset from the navigation host;
+ // padding this descendant cannot alter that system-supplied value.
+ .padding(.top, readoutTopOffset)
+ // Anchor to this safe-area-respecting child, not `readoutContent`.
+ // Its black sibling ignores the safe area and expands the enclosing
+ // ZStack past the display bottom, which would place a bottom-aligned
+ // banner off-screen — the same zero-inset trap this file has hit when
+ // geometry was read from an expanded region.
+ .overlay {
+ if let maxWidth = readoutFailureBannerMaxWidth {
+ VStack(spacing: 0) {
+ Spacer(minLength: 0)
+ compactCommandFailureBanner
+ // The transparent frame supplies a wrapping proposal and
+ // centres the toast; its material background remains on the
+ // intrinsic content rather than expanding permanent-looking
+ // chrome to the cap.
+ .frame(maxWidth: maxWidth)
+ }
+ // Three points keep the capsule's antialiasing visibly off the
+ // physical edge. Curvature needs no further allowance once both
+ // horizontal margins are at least the measured radius.
+ .padding(.bottom, Self.readoutFailureBannerBottomGap)
+ .ignoresSafeArea(edges: .bottom)
+ } else {
+ VStack(spacing: 0) {
+ Spacer(minLength: 0)
+ commandFailureBanner
+ }
+ // No measured radius means no permission to enter the bottom safe
+ // area; preserve the last known-safe placement exactly.
+ .padding(.horizontal, panelHorizontalInset)
+ .padding(.bottom, 4)
+ }
+ }
+ }
+ }
+
+ private var readoutTopOffset: CGFloat {
+ guard latchedTopSafeAreaInset > 0 else { return 0 }
+ return max(0, latchedTopSafeAreaInset - currentTopSafeAreaInset)
+ }
+
+ /// Map chrome remains an overlay so its measured frame can place the fix in
+ /// the visible band above it. The readout has its own hierarchy and therefore
+ /// cannot accidentally inherit this bottom-pinned card again.
+ private var mapOverlay: some View {
+ VStack(spacing: 0) {
+ HStack {
+ Spacer(minLength: 0)
+ recenterButton
+ }
+ Spacer(minLength: 0)
+ statusPanel
+ .overlay(alignment: .top) {
+ commandFailureBanner
+ .padding(.horizontal, 4)
+ // Aligning a guide below the banner with the panel's top puts the
+ // transient immediately above the measured card. Unlike adding a
+ // VStack row, an overlay contributes no size, so neither the
+ // panel's signed-off placement nor its measured height can move.
+ .alignmentGuide(.top) { dimensions in
+ dimensions[.bottom] + 3
+ }
+ }
+ .background(
+ GeometryReader { geo in
+ // Size, not frame: a global position moves with the page during
+ // an interactive swipe and closes a layout feedback loop.
+ Color.clear.preference(key: PanelHeightKey.self, value: geo.size.height)
+ }
+ )
+ }
+ // Earlier full-width versions clipped on curved hardware even though the
+ // simulator looked sound. Enter the bottom safe area only after the circle
+ // model has bought the matching horizontal clearance.
+ .padding(.horizontal, panelHorizontalInset)
+ .padding(.top, 2)
+ .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : panelBottomGap)
+ .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom)
+ }
+
+ /// A full-screen glance surface, not a map card without a map.
+ ///
+ /// Its content remains inside the system safe area and also keeps the
+ /// hardware-tested horizontal clearance. That is intentionally redundant at
+ /// the bottom corners: two earlier layouts passed in the simulator and
+ /// clipped on glass, while spare black pixels cost no compositing work.
+ private var readoutStatus: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if let snapshot {
+ ReadoutPhase(snapshot: snapshot, isLuminanceReduced: isLuminanceReduced)
+ } else {
+ Text("Waiting for iPhone")
+ .font(.system(size: 17, weight: .bold))
+ .foregroundStyle(.white.opacity(0.55))
+ .lineLimit(1)
+ }
+
+ Spacer(minLength: 6)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text("TOP HEARD")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.55))
+
+ if heard.isEmpty {
+ Text("Nothing heard")
+ .font(.system(size: 13, weight: .medium))
+ .foregroundStyle(.white.opacity(0.45))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ ForEach(Array(heard.prefix(4))) { node in
+ readoutHeardRow(node)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, panelHorizontalInset)
+ .padding(.vertical, 4)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .dynamicTypeSize(.small ... .large)
+ .opacity(client.isStale ? 0.5 : 1.0)
+ // Snapshot replacement may arrive with an animated transaction from an
+ // ancestor. Readout state changes are discrete; only the native precise
+ // countdown updates between them at full luminance.
+ .transaction { $0.animation = nil }
+ }
+
+ /// One full-width row is affordable without a basemap and gives the settled
+ /// type dot, hex identity and quality figure enough size for arm's-length
+ /// reading even when a six-character hash forces the overlay into one column.
+ private func readoutHeardRow(_ node: WatchHeardNode) -> some View {
+ HStack(spacing: 5) {
+ Circle()
+ .fill(Color(node.typeColor))
+ .frame(width: 8, height: 8)
+ Text(node.id)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.white)
+ Spacer(minLength: 6)
+ if let snr = node.snr {
+ Text(snr, format: .number.precision(.fractionLength(1)))
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundStyle(node.snrColor.map(Color.init) ?? .white)
+ .frame(width: 42, alignment: .trailing)
+ }
+ }
+ .lineLimit(1)
+ }
+
+ private func latchBottomSafeAreaInset(_ inset: CGFloat) {
+ guard bottomSafeAreaInset == 0, inset > 0 else { return }
+ bottomSafeAreaInset = inset
+ }
+
+ private func noteTopSafeAreaInset(_ inset: CGFloat) {
+ guard inset.isFinite else { return }
+ currentTopSafeAreaInset = max(0, inset)
+
+ // Only the full-luminance readout is the approved reference; the map extends
+ // beneath top chrome and reports a different inset. Keep its largest value
+ // because the navigation bar settles upward after installing its toolbar.
+ // This intentionally differs from the bottom inset's first-nonzero latch:
+ // the bottom value drives padding and can feed back into its measurement,
+ // while the top value comes from the parent navigation host and drives
+ // padding only inside its readout child, which cannot change that inset.
+ guard !isLuminanceReduced, !showsMap, inset > 0 else { return }
+ latchedTopSafeAreaInset = max(latchedTopSafeAreaInset, inset)
+ }
+
+ /// Phase and Top Heard in one panel.
+ ///
+ /// Rows are `[type dot] [hex ID] [SNR]`. The hex path hash is the identity,
+ /// because a 1-byte hash frequently cannot be resolved to a single repeater;
+ /// a name is appended only when the phone could resolve it unambiguously.
+ private var statusPanel: some View {
+ Button {
+ // Only a sheet placement has anything to open. With the list on its own
+ // page this was presenting a duplicate of a page that already exists —
+ // and because the panel sits across the bottom of the map, where an
+ // upward page swipe begins, it fired on swipes the wearer meant for the
+ // pager and stranded a modal over the next page.
+ guard settings.nodeListPlacement == .sheet else { return }
+ showingNodes = true
+ } label: {
+ VStack(alignment: .leading, spacing: 3) {
+ timerBar
+
+ if heard.isEmpty {
+ Text("Nothing heard")
+ .font(.system(size: 10))
+ .foregroundStyle(.white.opacity(0.45))
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ // Two columns are the useful glance layout even on 40 mm, so type
+ // shrinks against the same width model as the actual row. Only a
+ // six-character zone that would cross the 7 pt legibility floor gets
+ // one column; truncating the ID is not an option because it is the
+ // repeater's identity.
+ if twoHeardColumnsFit {
+ HStack(alignment: .top, spacing: columnGap) {
+ heardColumn(Array(heard.prefix(2)))
+ heardColumn(Array(heard.dropFirst(2)))
+ }
+ } else {
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(heard) { heardRow($0) }
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ // Bright basemap labels bleed through flat translucency and fight the
+ // SNR digits; material removes that competing detail on the map only.
+ .background(.ultraThinMaterial, in: .rect(cornerRadius: 12, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .strokeBorder(.white.opacity(0.12), lineWidth: 0.5)
+ )
+ }
+ .buttonStyle(.plain)
+ // A HUD, not body copy: fixed sizes keep it from swallowing the map at
+ // large accessibility text sizes. The scrollable detail list is where the
+ // wearer's text-size setting is honoured.
+ .dynamicTypeSize(.small ... .large)
+ .opacity(client.isStale ? 0.5 : 1.0)
+ }
+
+ /// A stable gutter keeps the width equation and the rendered columns in
+ /// agreement; changing it by device size would silently spend the room the
+ /// font calculation just recovered on the smallest watch.
+ private var columnGap: CGFloat { 8 }
+
+ /// One column of heard rows.
+ private func heardColumn(_ nodes: [WatchHeardNode]) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ ForEach(nodes) { heardRow($0) }
+ }
+ }
+
+ /// `[type dot] [hex ID] [SNR]`, sized to its content.
+ ///
+ /// Content-sized on purpose so the row's intrinsic measurement stays equal
+ /// to the width model that chooses the font and column count.
+ private func heardRow(_ node: WatchHeardNode) -> some View {
+ HStack(spacing: 3) {
+ Circle()
+ .fill(Color(node.typeColor))
+ .frame(width: 6, height: 6)
+ Text(node.id)
+ .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced))
+ .foregroundStyle(.white)
+ if let snr = node.snr {
+ Text(snr, format: .number.precision(.fractionLength(1)))
+ .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced))
+ .foregroundStyle(node.snrColor.map(Color.init) ?? .white)
+ // Fixed width so SNRs line up down a column despite varying digits.
+ .frame(width: rowFontSize * 3.1, alignment: .trailing)
+ }
+ }
+ .lineLimit(1)
+ .fixedSize()
+ }
+
+ /// The phase as a depleting bar with its remaining time.
+ ///
+ /// The bar drains right to left from `phaseEndsAt` and `phaseDurationMs`,
+ /// both absolute, so it is correct without per-second updates and correct
+ /// when the app opens midway through a phase. A stale payload replaces the
+ /// whole row: a bar that keeps draining against a deadline the phone has
+ /// stopped confirming is worse than no bar.
+ @ViewBuilder
+ private var timerBar: some View {
+ if client.isStale, let receivedAt = client.receivedAt {
+ HStack(spacing: 3) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 8))
+ Text(receivedAt, style: .relative)
+ .font(.system(size: 10, weight: .medium))
+ Text("old")
+ .font(.system(size: 10))
+ Spacer(minLength: 0)
+ }
+ .foregroundStyle(.orange)
+ .lineLimit(1)
+ } else if let snapshot {
+ WatchPhaseBar(snapshot: snapshot, isLuminanceReduced: isLuminanceReduced)
+ .frame(height: 15)
+ }
+ }
+
+ /// Largest size allowed by the hash-length ladder. The watch now permits one
+ /// point more than the phone-derived sizes: it is read at arm's length in
+ /// motion, and the width solver still prevents that legibility gain from
+ /// making an intrinsic row overflow its column.
+ private var rowFontSizeCap: CGFloat {
+ let widest = heard.map(\.id.count).max() ?? 2
+ if widest > 4 { return 10 }
+ if widest > 2 { return 11 }
+ return 12
+ }
+
+ /// Font size that leaves two intrinsic rows safely inside their columns.
+ /// The constants mirror `heardRow`: dot and gaps consume 12 pt, semibold
+ /// monospaced IDs advance closer to 0.62 em than the nominal 0.6, and the
+ /// aligned SNR owns 3.1 em. Two points of slack make measurement error land
+ /// on smaller type rather than a wider panel that defeats its edge clearance.
+ private var unconstrainedRowFontSize: CGFloat {
+ let idChars = CGFloat(heard.map(\.id.count).max() ?? 2)
+ let columnWidth = (panelContentWidth - columnGap) / 2 - 2
+ return (columnWidth - 12) / (0.62 * idChars + 3.1)
+ }
+
+ /// Eight points is the new glanceability floor for two columns. Without it,
+ /// the extra width makes six-character IDs on 40 mm barely “fit” at about
+ /// seven points and regress from the clearer single-column treatment.
+ private var twoHeardColumnsFit: Bool { unconstrainedRowFontSize >= 8 }
+
+ private var rowFontSize: CGFloat {
+ guard twoHeardColumnsFit else { return rowFontSizeCap }
+ return min(unconstrainedRowFontSize.clamped(to: 8...12), rowFontSizeCap)
+ }
+
+ private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] }
+
+ @ViewBuilder
+ private var recenterButton: some View {
+ if !isFollowing, fix != nil {
+ Button {
+ recenterIfFollowing(force: true)
+ } label: {
+ Image(systemName: "location.fill")
+ .font(.system(size: 10))
+ }
+ .buttonStyle(.borderless)
+ .padding(4)
+ .background(.black.opacity(0.5), in: Circle())
+ }
+ }
+
+ // MARK: - Map
+
+ private var map: some View {
+ // Pan consumes the vertical gesture the page shell needs, while the Crown
+ // remains the deliberate zoom control. Once drag input is absent, centre
+ // drift cannot honestly identify a pan — MapKit and Crown zoom can both
+ // produce it — so follow has no distance-based suspension path to misfire.
+ Map(position: $camera, interactionModes: [.zoom]) {
+ linkLines
+ pingMarkers
+ repeaterPins
+ fixMarker
+ }
+ .mapStyle(.standard)
+ // Tell MapKit what is covering the map, rather than hand-shifting the
+ // camera to compensate. Its own centre then *is* the centre of the band
+ // the wearer can actually see, so the fix lands there without a correction
+ // pass. A Crown zoom holds it there across the useful range — measured
+ // drift is 0.000000 degrees from 0.0002 up to about 21 degrees of span —
+ // but *not* past roughly 22 degrees, where MapKit shifts the centre north
+ // and leaves it there. `recentreAfterUserZoom` repairs that; the framing
+ // here is not what breaks.
+ //
+ // Both edges matter. Insetting only the bottom centres the fix in
+ // `0...panelTop`, which still includes the toolbar strip, and the puck then
+ // reads high — obviously so on 46 mm, where the chrome is a smaller
+ // fraction of the display.
+ .safeAreaPadding(.top, currentTopSafeAreaInset)
+ .safeAreaPadding(.bottom, panelCameraInset)
+ .onMapCameraChange(frequency: .onEnd) { context in
+ // `.onEnd` matters: the correction below must never run mid-gesture, or
+ // it would fight the Crown while the wearer is still turning it.
+ noteRenderedRegion(context.region, cameraCentre: context.camera.centerCoordinate)
+ }
+ // The shell's navigation host supplies the system toolbar placement but
+ // must not buy it by shortening the basemap. Only MapKit extends under that
+ // top chrome; the overlay remains in the safe content region, keeping its
+ // inset recentre button below the system toolbar controls.
+ .ignoresSafeArea(edges: [.top, .bottom])
+ // Dim the basemap for the length of the hold rather than tearing it down.
+ //
+ // **The hold's real cost is lit pixels, not CPU.** An earlier comment
+ // priced it as "MapKit stays constructed and rendering", but watchOS
+ // suspends this app about a second into a wrist-down, so almost none of
+ // that rendering happens — what does happen is a bright basemap held on an
+ // OLED always-on display for the length of the hold, per glance, whether or
+ // not the app is scheduled to draw it. Apple's Always-On
+ // guidance names this directly: "if you display rich images or large areas
+ // of color, consider removing the images and using dimmed colors."
+ //
+ // A scrim buys that back without giving up the hold, which exists to stop
+ // the map flickering out from under a wearer who is still looking at it.
+ //
+ // **Value, never presence.** An overlay that appeared at the dim would
+ // re-identify the subtree beneath it and rebuild all of MapKit at exactly
+ // the wrong moment — the defect `pageContent`'s background note records.
+ // This one is always in the tree and only its opacity changes.
+ // **0.70, and the number is measured rather than chosen by eye.** A watch
+ // powerlog gives content brightness per sample as `AvgAPL`, and across a
+ // 90-minute walk the surfaces separate cleanly: a bright map sits at APL
+ // 135, this scrim plus Always-On took it to 72, and the readout that
+ // eventually replaces it sits at 30.5 in the same dim state. So the held
+ // map was still 2.4x the readout's content brightness — on an OLED, where
+ // power tracks lit pixels, that is the cost the hold was paying.
+ //
+ // Luminance scales as `basemap * (1 - opacity)`, and the numbers agree: 72
+ // at 0.45 implies an unscrimmed 131, against a measured 135. Solving for
+ // the readout's 30.5 plus a little margin gives 0.70.
+ //
+ // **This is the lever, and the hold's duration is not.** Measured on the
+ // same walk: 63 map episodes, median 32.7 s, against the ~17.9 s this hold
+ // is supposed to cap them at — because the ticker below is throttled to
+ // roughly one wake a minute under Always On, so the readout returns when
+ // watchOS gets round to it and not when `dimmedMapHold` expires. Shortening
+ // the constant would not shorten the episodes; darkening the pixels they
+ // spend does. See `dimmedMapHoldTicker`, which already predicted the
+ // overshoot this measures.
+ .overlay {
+ Color.black
+ .opacity(isLuminanceReduced ? 0.70 : 0)
+ .ignoresSafeArea()
+ .allowsHitTesting(false)
+ }
+ }
+
+ @MapContentBuilder
+ private var linkLines: some MapContent {
+ if settings.showLinks, let fix, let snapshot {
+ ForEach(linkedRepeaters(in: snapshot.geo)) { repeater in
+ MapPolyline(coordinates: [
+ fix,
+ CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon),
+ ])
+ .stroke(Color(repeater.color).opacity(0.7), lineWidth: 1.5)
+ }
+ }
+ }
+
+ /// Heard identities are path-hash prefixes, not API database IDs. Resolve
+ /// against the full hex carried by each pin, and require one match per
+ /// prefix: a line asserts a real radio path, so an ambiguous line is worse
+ /// than drawing none. The phone performs the same check against the full
+ /// catalogue before sending; repeating it here keeps malformed or older
+ /// payloads from turning ambiguity into a visual claim.
+ private func linkedRepeaters(in geo: WatchGeo) -> [WatchRepeater] {
+ var resolved = [WatchRepeater]()
+ var seen = Set()
+
+ for rawPrefix in geo.linkedRepeaterIds {
+ let prefix = rawPrefix.uppercased()
+ guard !prefix.isEmpty else { continue }
+ let matches = geo.repeaters.filter {
+ $0.hexId?.uppercased().hasPrefix(prefix) == true
+ }
+ guard matches.count == 1, seen.insert(matches[0].id).inserted else { continue }
+ resolved.append(matches[0])
+ }
+ return resolved
+ }
+
+ @MapContentBuilder
+ private var pingMarkers: some MapContent {
+ if let snapshot {
+ ForEach(snapshot.geo.pings) { ping in
+ Annotation("", coordinate: CLLocationCoordinate2D(latitude: ping.lat, longitude: ping.lon)) {
+ // The phone's marker style is a user preference the watch does not
+ // receive, so mirror its default dot: a filled circle with a soft
+ // white border. The phone's shadow is omitted at this wrist scale.
+ Circle()
+ .fill(Color(ping.color))
+ .frame(width: 6, height: 6)
+ .overlay(
+ Circle()
+ .strokeBorder(.white.opacity(0.6), lineWidth: 0.75)
+ )
+ }
+ .annotationTitles(.hidden)
+ }
+ }
+ }
+
+ @MapContentBuilder
+ private var repeaterPins: some MapContent {
+ if let snapshot {
+ ForEach(snapshot.geo.repeaters) { repeater in
+ Annotation("", coordinate: CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon)) {
+ RepeaterPin(color: Color(repeater.color), highlighted: repeater.heardThisCycle)
+ }
+ .annotationTitles(.hidden)
+ }
+ }
+ }
+
+ @MapContentBuilder
+ private var fixMarker: some MapContent {
+ // `displayedFix` for the position, `you` for the heading — the first is the
+ // eased rendering of the second's coordinate, and both must be present for
+ // the puck to mean anything.
+ if let displayedFix, let you = snapshot?.geo.you {
+ Annotation("", coordinate: displayedFix) {
+ // The phone's fix, not the watch's. Rendering it ourselves keeps the
+ // watch free of any location permission.
+ FixPuck(headingDeg: you.headingDeg)
+ }
+ .annotationTitles(.hidden)
+ }
+ }
+
+ // MARK: - Camera
+
+ /// How a follow step should move, or `nil` to cut.
+ ///
+ /// **Reduced luminance cuts, on the same rule as `WatchPhaseBar.drainsFill`.**
+ /// Always-On repaints roughly once a minute, so an eased camera there buys no
+ /// visible motion and spends MapKit render work anyway — and the dim hold
+ /// deliberately keeps this map on screen for `dimmedMapHold` seconds past the
+ /// dim, which is exactly the window where it would otherwise be animating for
+ /// nobody. Geo can still arrive in that window: `needsMapGeo` goes false at
+ /// the dim but `WatchBridgeService` only schedules suppression 15 s out.
+ ///
+ /// 0.7 s is chosen against the update cadence, not by feel. The phone's
+ /// movement gate is 15 m and its non-urgent throttle is 2 s, so follow steps
+ /// arrive about 11 s apart at walking pace and never closer than 2 s. A curve
+ /// comfortably inside that floor means one step finishes before the next is
+ /// retargeted, so the common case is a clean glide rather than a chain of
+ /// interrupted animations.
+ private var followAnimation: Animation? {
+ guard !isLuminanceReduced else { return nil }
+ return .easeInOut(duration: 0.7)
+ }
+
+ /// A step larger than this is treated as a resumed stream, not as travel.
+ ///
+ /// Updates are at least 2 s apart, so even a sprint moves well under this
+ /// between them. Anything further means delivery was interrupted — a
+ /// suppressed geo claim, a wrist-down, a dropped session — and sliding the
+ /// map across that gap would draw a whoosh through territory the wearer never
+ /// walked. Cut instead, which is what this page did for every step before.
+ private static let followAnimationCeilingM: CLLocationDistance = 100
+
+ /// Move the puck and the camera together, so a follow step slides the world
+ /// rather than cutting to it.
+ ///
+ /// The 15 m movement gate on the phone means a walking wearer gets one step
+ /// every ~11 s, and at the 250 m default zoom 15 m is around 12 pt of screen —
+ /// large enough to read as a jump rather than as motion.
+ ///
+ /// Both assignments have to sit in the same `withAnimation` for the reason
+ /// `displayedFix` exists at all. `recenterIfFollowing` reaches `applyRegion`
+ /// with `animated: false`, which here means "add no animation of your own" —
+ /// the assignment inherits this transaction instead. Its own 0.25 s ease is
+ /// still right for the paths that pass `animated: true`, which are discrete
+ /// wearer-visible events rather than tracking.
+ private func trackFix() {
+ let previous = displayedFix
+ guard let animation = followAnimation,
+ let previous,
+ let fix,
+ CLLocation(latitude: previous.latitude, longitude: previous.longitude)
+ .distance(from: CLLocation(latitude: fix.latitude, longitude: fix.longitude))
+ <= Self.followAnimationCeilingM
+ else {
+ // Covers a cut for every reason: dimmed, a first fix with nothing to
+ // travel from, geo withdrawn, or a step too large to be travel. Clearing
+ // to `nil` matters — a stale rendered position left standing would become
+ // the origin of the next animation.
+ displayedFix = fix
+ recenterIfFollowing()
+ return
+ }
+
+ withAnimation(animation) {
+ displayedFix = fix
+ // Follow off still animates the puck: it slides to its true place across
+ // a map the wearer chose to hold still, which is the same improvement for
+ // the case where the camera is deliberately not tracking.
+ recenterIfFollowing()
+ }
+ }
+
+ /// `animated` defaults to following `force` — a tap is the case that wants to
+ /// be shown to the wearer. The panel settle passes it explicitly, because it
+ /// is not a forced recentre (Follow off must still mean Follow off) and yet it
+ /// is a deliberate, isolated change that reads better eased than snapped.
+ private func recenterIfFollowing(force: Bool = false, animated: Bool? = nil) {
+ // Every early return here leaves `camera` untouched, and an untouched
+ // camera can still be `.automatic` — which fits every annotation and has
+ // been measured rendering 34.4 degrees, about 3,800 km. Log which clause
+ // bailed: "no fix" and "follow off" are indistinguishable in the span log
+ // and want different fixes.
+ #if DEBUG
+ if !(showsMap && (force || isFollowing) && fix != nil) {
+ WakeLog.note(
+ "recenter-skipped showsMap \(showsMap) following \(isFollowing) "
+ + "fix \(fix == nil ? "nil" : "yes") force \(force)"
+ )
+ }
+ #endif
+ guard showsMap, force || isFollowing, let fix else { return }
+ // Centre on the fix itself. The camera inset already accounts for the
+ // panel, so no compensating shift is needed and nothing has to be
+ // corrected after the map reports back.
+ //
+ // A tap is a rare, explicit request to move the map, so animation shows the
+ // wearer what their action changed. Automatic follow passes `false` and is
+ // eased by its caller instead: `trackFix` wraps this call and the puck's
+ // position in one transaction, which is the only arrangement where the
+ // camera can move without the puck wandering away from centre first.
+ applyRegion(center: fix, animated: animated ?? force)
+ }
+
+ /// Guarantee this native map has a region of ours, whatever `recenterIfFollowing`
+ /// decided.
+ ///
+ /// **`.automatic` is a terminal state, and this is what ends it.** Every early
+ /// return in `recenterIfFollowing` leaves `camera` untouched, and an untouched
+ /// camera can still be `.automatic` — which fits every annotation and was
+ /// measured on a Series 9 rendering 34.364390 degrees of latitude, about
+ /// 3,800 km. Nothing else in this file assigns a region, so once the camera
+ /// landed there only a manual Crown zoom escaped it: twenty consecutive
+ /// callbacks at that span, across five separate wrist raises.
+ ///
+ /// The wake log is specific about when it happens. Wakes preceded only by
+ /// follow updates restored perfectly — first callback `drift 0.0%`, every
+ /// time. The one wake that came back continental was the one preceded by a
+ /// burst of Crown zooming, which suggests the interaction leaves `camera` in a
+ /// state that does not survive the subtree teardown. This does not depend on
+ /// that being the mechanism: it asserts a region regardless of how the camera
+ /// got where it is.
+ ///
+ /// Follow governs *tracking*, not whether a region is ever asserted. Turning
+ /// Follow off must not be able to strand the map at a continental span.
+ private func anchorCameraIfNeeded() {
+ guard showsMap, !hasAssertedRegion, let center = anchorCenter else { return }
+ applyRegion(center: center, animated: false)
+ }
+
+ /// Where a map with no follow-driven centre should open.
+ ///
+ /// Ordered by how well each source reflects what the wearer last saw. With
+ /// Follow off a *recent* centre wins over the live fix on purpose — restoring
+ /// the view they left is the whole point, and jumping to the fix is precisely
+ /// the tracking they turned off.
+ ///
+ /// The live fix outranks a stale one because this map cannot pan
+ /// (`interactionModes: [.zoom]`), so opening on a centre the wearer has since
+ /// travelled away from strands them somewhere they can only zoom. A stale
+ /// centre is still preferred over nothing: it is a real place the wearer once
+ /// was, which beats `.automatic` fitting every annotation at 3,800 km.
+ private var anchorCenter: CLLocationCoordinate2D? {
+ if isFollowing, let fix { return fix }
+ if let programmaticCenter { return programmaticCenter }
+ return settings.freshMapCenter ?? fix ?? settings.lastMapCenter
+ }
+
+ /// The one place `camera` is assigned, so every path records the centre and
+ /// marks the map anchored.
+ ///
+ /// `span` defaults to the persisted zoom, which is right for every caller
+ /// except the post-zoom correction: that one has to carry the span MapKit
+ /// just rendered, or correcting the centre would also undo the wearer's zoom.
+ private func applyRegion(
+ center: CLLocationCoordinate2D,
+ span: MKCoordinateSpan? = nil,
+ animated: Bool
+ ) {
+ #if DEBUG
+ // The other half of the boundary probe: the two handlers above say what
+ // changed, this says whether the camera actually moved because of it.
+ if isNearPhaseBoundary {
+ WakeLog.note(
+ String(
+ format: "boundary camera %.6f,%.6f span %.6f animated %@",
+ center.latitude,
+ center.longitude,
+ (span ?? currentSpan).latitudeDelta,
+ animated ? "yes" : "no"
+ )
+ )
+ }
+ #endif
+ programmaticCenter = center
+ hasAssertedRegion = true
+ settings.noteMapCenter(center)
+ let region = MKCoordinateRegion(center: center, span: span ?? currentSpan)
+
+ // DO NOT re-add a "skip when the region is unchanged" guard here. One was
+ // tried and reverted: it bought no measurable wake latency (0.43/0.46/0.47 s
+ // against a 0.15 s sampling floor) and it removed a repair. This assignment
+ // is not merely redundant — it is the only thing that re-asserts our region
+ // over whatever the camera drifted to, including `.automatic`, whose
+ // annotation fit spans a continent. Adam raised his wrist to a map showing
+ // the whole United States while that guard was installed.
+ if animated {
+ withAnimation(.easeInOut(duration: 0.25)) {
+ camera = .region(region)
+ }
+ } else {
+ // "No animation of our own", not "no animation". First placement, the
+ // anchor and the post-zoom correction all reach here outside any
+ // transaction and cut, which is what they want. A follow step reaches it
+ // from inside `trackFix`'s `withAnimation` and inherits that curve, so
+ // the camera travels on exactly the same one as the puck.
+ camera = .region(region)
+ }
+ }
+
+ #if DEBUG
+ /// `positionedByUser` is included because a Crown zoom is the one interaction
+ /// that precedes the stuck camera in the wake log, and it is the only public
+ /// signal that MapKit rather than we last moved the camera.
+ private var cameraStateDescription: String {
+ "automatic \(camera == .automatic) byUser \(camera.positionedByUser) "
+ + "anchored \(hasAssertedRegion) fix \(fix == nil ? "nil" : "yes") "
+ + "following \(isFollowing)"
+ }
+ #endif
+
+ /// MapKit fits longitude to the watch's aspect ratio, so latitude is the one
+ /// independent zoom value. After the one-time defaults migration it starts
+ /// at 0.00225 degrees, about 250 m north-south, and later launches reuse the
+ /// wearer's Crown setting.
+ private var currentSpan: MKCoordinateSpan {
+ MKCoordinateSpan(
+ latitudeDelta: settings.mapLatitudeDelta,
+ longitudeDelta: settings.mapLatitudeDelta
+ )
+ }
+
+ /// Track the region MapKit actually rendered, so a Digital Crown zoom is not
+ /// thrown away on the next follow update.
+ private func noteRenderedRegion(
+ _ region: MKCoordinateRegion,
+ cameraCentre: CLLocationCoordinate2D
+ ) {
+ // Latched before anything below can assign the camera and clear it.
+ let wasPositionedByUser = camera.positionedByUser
+
+ // Deferred because the span write-back below has four early returns and the
+ // centre needs repairing on every one of them — the northward shift arrives
+ // on a zoom-out large enough that the write-back may well bail.
+ defer {
+ recentreAfterUserZoom(
+ wasPositionedByUser: wasPositionedByUser,
+ renderedSpan: region.span
+ )
+ }
+
+ // A wearer who works the Crown on a map we never anchored has stated a
+ // camera preference, and it outranks our seed. Without this, the sequence
+ // "launch with no fix -> wearer zooms -> first fix arrives" ends with
+ // `anchorCameraIfNeeded` replacing their zoom with the persisted span,
+ // because `hasAssertedRegion` was still false. Marking it here retires the
+ // anchor without asserting anything, which is exactly the intent: the map
+ // is positioned, just not by us.
+ if wasPositionedByUser {
+ hasAssertedRegion = true
+ }
+
+ #if DEBUG
+ if wasPositionedByUser, let fix {
+ // Quantifies the zoom-anchor drift. The wearer reported that zooming in
+ // loses the centre faster than zooming out; this is the number behind it,
+ // and it should read near zero once the correction below has run.
+ WakeLog.note(
+ String(
+ format: "user-zoom drift lat %.6f lon %.6f span %.6f",
+ cameraCentre.latitude - fix.latitude,
+ cameraCentre.longitude - fix.longitude,
+ region.span.latitudeDelta
+ )
+ )
+ }
+ #endif
+
+ // `.automatic` can report its annotation fit even after we assign our first
+ // region, so `programmaticCenter != nil` proves only that a request was
+ // made, not that MapKit rendered it. Persist nothing until the rendered
+ // span confirms the request; later deviations are Crown zooms and remain
+ // eligible for the normal write-back below.
+ guard programmaticCenter != nil else { return }
+
+ let rendered = region.span.latitudeDelta
+ let requested = settings.mapLatitudeDelta
+ guard rendered.isFinite, requested.isFinite, requested > 0 else { return }
+
+ #if DEBUG
+ WakeLog.note(
+ String(
+ format: "span rendered %.6f requested %.6f confirmed %@ drift %.1f%%",
+ rendered, requested, hasConfirmedRequestedSpan ? "Y" : "N",
+ abs(rendered - requested) / requested * 100
+ )
+ )
+ #endif
+
+ guard hasConfirmedRequestedSpan else {
+ let relativeDifference = abs(rendered - requested) / requested
+ if relativeDifference <= Self.spanConfirmationTolerance {
+ hasConfirmedRequestedSpan = true
+ }
+ return
+ }
+
+ // Only on a real change. Every follow update produces a camera change, and
+ // writing an identical value would persist and invalidate on each one,
+ // re-rendering the map for nothing.
+ guard abs(rendered - requested) / requested > 0.01 else { return }
+ settings.mapLatitudeDelta = rendered
+ }
+
+ /// Put the fix back at the centre after the wearer has zoomed.
+ ///
+ /// **What actually drifts, measured on a Series 9.** A Crown zoom holds the
+ /// camera centre exactly — drift is 0.000000 degrees through spans of 0.0002
+ /// up to about 21 degrees. Past roughly 22 degrees MapKit moves the centre
+ /// *north* and does not put it back: measured jumps of 0.074080 degrees
+ /// (8.2 km) at a 24.5-degree span and 0.126004 degrees (14.0 km) at a
+ /// 25.0-degree span. The shift is **sticky** — it survives every later zoom,
+ /// including a zoom all the way back in to 0.000229, so the wearer ends up
+ /// looking at a point 8-14 km north of themselves at street level. Only a
+ /// wrist drop, which rebuilds the map, cleared it.
+ ///
+ /// That is exactly what was reported: "zoom out seems to stay centered
+ /// initially, but zoom didn't stay centered (missed to the north,
+ /// significantly). Re-centered after drop/raise."
+ ///
+ /// **This function was once deleted, and the deletion was a mistake worth
+ /// recording.** An earlier reading of the same probe showed drift pinned at
+ /// zero and concluded there was nothing to repair. That run had this
+ /// correction installed: the probe samples at the start of the *next* camera
+ /// change, so it was reading a centre this function had just repaired. The
+ /// zero was the repair working. Before removing it again, remove it *first*
+ /// and re-measure — a metric taken through a repair cannot evaluate it.
+ ///
+ /// **The span is preserved, not re-asserted.** Using `currentSpan` here would
+ /// pull the zoom back to the persisted (and clamped) value on every turn of
+ /// the Crown, which reads as the map refusing to zoom. Only the centre moves.
+ ///
+ /// **Termination.** Gated on `positionedByUser`, which MapKit sets for an
+ /// interactive change and our own assignment clears, so this can trigger
+ /// exactly one more camera change and that one does not re-enter. Confirmed
+ /// on device: `byUser true` appears only after a real Crown zoom.
+ ///
+ /// Following only — with Follow off the camera is the wearer's.
+ private func recentreAfterUserZoom(wasPositionedByUser: Bool, renderedSpan: MKCoordinateSpan) {
+ guard wasPositionedByUser, isFollowing, let fix else { return }
+ // Unconditional, like every other assertion in this file. When the centre
+ // has not drifted this re-asserts the same region, which is the cheap case;
+ // see `applyRegion` for what a "skip when unchanged" guard cost last time.
+ applyRegion(center: fix, span: renderedSpan, animated: false)
+ }
+
+}
+
+/// A phase-scoped progress animation rather than a one-second render clock.
+///
+/// `Text(timerInterval:)` owns its countdown without invalidating this view.
+/// The fill is set once from the absolute deadline and driven to zero by a
+/// single transform animation; one sleeping task wakes at the deadline solely
+/// to retire the countdown and dim a claim the phone has not refreshed.
+private struct WatchPhaseBar: View {
+ let snapshot: WatchSnapshot
+ /// Passed in rather than read from the environment so the DEBUG dim
+ /// overrides on `MapPage` reach it — the whole panel is captured through
+ /// them, and a bar that kept animating would be the one thing that did not.
+ let isLuminanceReduced: Bool
+
+ @State private var remainingFraction: CGFloat
+ @State private var deadlineLapsed: Bool
+
+ init(snapshot: WatchSnapshot, isLuminanceReduced: Bool) {
+ self.snapshot = snapshot
+ self.isLuminanceReduced = isLuminanceReduced
+ let now = Date()
+ _remainingFraction = State(
+ initialValue: CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0)
+ )
+ _deadlineLapsed = State(
+ initialValue: snapshot.phaseEndsAt.map { $0 <= now } ?? false
+ )
+ }
+
+ /// Whether the fill should be animated at all, as opposed to snapped to the
+ /// deadline's current truth and left there.
+ ///
+ /// **Reduced luminance stops it.** Always-On updates the screen at roughly
+ /// one frame a minute, so a drain animation there buys no visible motion and
+ /// spends energy anyway; Apple's Always-On guidance is to pause animations
+ /// and drop subsecond work while the app is inactive. The map now stays
+ /// constructed for up to `dimmedMapHold` seconds past the dim, which is
+ /// exactly when this bar would otherwise keep animating over a screen nobody
+ /// can see move.
+ private var drainsFill: Bool {
+ #if DEBUG
+ // A/B harness for the drain's energy cost, under Settings -> Instruments.
+ if WatchSettings.debugFreezesTimerBar { return false }
+ #endif
+ return !isLuminanceReduced
+ }
+
+ private var phaseKey: PhaseAnimationKey {
+ PhaseAnimationKey(
+ endsAtMs: snapshot.phaseEndsAtMs,
+ durationMs: snapshot.phaseDurationMs,
+ drainsFill: drainsFill
+ )
+ }
+
+ /// Build the native timer's range at render time so a deadline crossing
+ /// between the sleeping task and a body update can never form `now...past`.
+ private var activeCountdownRange: ClosedRange? {
+ let now = Date()
+ guard !deadlineLapsed, let endsAt = snapshot.phaseEndsAt, endsAt > now else {
+ return nil
+ }
+ return now...endsAt
+ }
+
+ var body: some View {
+ ZStack(alignment: .leading) {
+ Capsule().fill(.white.opacity(0.16))
+ Capsule()
+ .fill(snapshot.pingColor.map(Color.init) ?? .accentColor)
+ // **A transform, not a width.** Animating `frame(width:)` re-runs
+ // SwiftUI's layout for the whole panel on every frame of a drain that
+ // lasts the entire phase; a leading-anchored scale is one affine
+ // transform handed to the render server and left alone.
+ //
+ // **Scale the mask, not the capsule.** Scaling the capsule itself
+ // squashes its end caps, so the bar started rounded and finished with
+ // square ends — visible on the wrist at 15 pt tall, where an earlier
+ // comment here guessed it would not be. Masking keeps the capsule's
+ // own geometry untouched, so the leading cap stays round and only the
+ // trailing edge becomes a flat cut, and the animated property is still
+ // a single affine transform on a solid shape.
+ .mask(alignment: .leading) {
+ Rectangle()
+ .scaleEffect(x: remainingFraction, y: 1, anchor: .leading)
+ }
+ }
+ .overlay {
+ HStack {
+ // A missing deadline describes a durable state. A passed one is only
+ // the phone's last claim, so keep it visible but no longer assert it.
+ Text(snapshot.phaseTitle)
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1))
+ .lineLimit(1)
+ .truncationMode(.tail)
+ Spacer(minLength: 4)
+ if let range = activeCountdownRange {
+ // Fixed width because the native timer otherwise reserves room for
+ // its widest possible value and starves the title.
+ Text(timerInterval: range, countsDown: true)
+ .font(.system(size: 11, weight: .bold).monospacedDigit())
+ .foregroundStyle(.white)
+ .frame(width: 38, alignment: .trailing)
+ }
+ }
+ // The fill slides under both labels, so a shadow keeps them readable
+ // against the filled and empty parts of the track.
+ .shadow(color: .black.opacity(0.7), radius: 1.5)
+ .padding(.horizontal, 6)
+ }
+ .task(id: phaseKey) {
+ await runPhaseAnimation()
+ }
+ }
+
+ @MainActor
+ private func runPhaseAnimation() async {
+ let now = Date()
+ let fraction = CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0)
+ let lapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false
+
+ // A replacement phase must start at its true current fraction, not animate
+ // from the previous phase's endpoint before beginning its own drain. This
+ // is also what makes the dim honest: the task re-runs when `drainsFill`
+ // flips, so the frozen bar is snapped to the deadline's truth rather than
+ // stranded wherever the cancelled animation happened to be.
+ //
+ // **A zero-duration animation, not `disablesAnimations`.** Suppressing the
+ // transaction leaves the running drain in place, and interrupting a
+ // transform animation reverts the layer to its model value — measured in
+ // the simulator as a bar that snapped back to *full* at the dim and stayed
+ // there, while the countdown beside it ran on. Replacing the animation
+ // rather than opting out of one is what actually ends it.
+ //
+ // **And replacing it requires the value to actually change.** While a drain
+ // runs, the *model* value here is already 0: `withAnimation` set it there at
+ // the start and is interpolating only the presentation toward it. So this
+ // assignment is a no-op precisely when the new truth is also 0 — which is
+ // the stopped-session case — and the in-flight drain survives untouched.
+ // Adam saw exactly that on the wrist: the title changed to "Ready" while
+ // the fill kept draining to the old deadline. The dim never showed it
+ // because a dim mid-phase has a non-zero truth, so the assignment was a
+ // real change and did replace the animation.
+ //
+ // Hence the nudge: one zero-duration transaction to a value that is
+ // certainly different, which SwiftUI must take and which abandons the
+ // animation in flight, then the real one to the truth. Both are
+ // zero-duration, so neither is visible.
+ // The nudge must land in its own transaction. SwiftUI batches state changes
+ // made in one run-loop turn, so a nudge and the real assignment written back
+ // to back coalesce into a single change that nets to nothing — the very
+ // no-op being escaped. `Task.yield()` separates them, which is the same
+ // reason the drain below already yields before it starts.
+ if remainingFraction == fraction {
+ withAnimation(.linear(duration: 0)) {
+ remainingFraction = fraction > 0.5 ? fraction - 0.001 : fraction + 0.001
+ }
+ await Task.yield()
+ }
+ withAnimation(.linear(duration: 0)) {
+ remainingFraction = fraction
+ deadlineLapsed = lapsed
+ }
+
+ #if DEBUG
+ // Measure instead of reasoning about it: the stopped-session drain has now
+ // survived one reasoned fix, so record what this task actually sees. If no
+ // line appears on a stop, `phaseKey` did not change and the task never
+ // re-ran; if one appears with `endsAt nil fraction 0.000` while the fill is
+ // still visibly draining, the animation is outliving the state and no
+ // assignment here will end it.
+ WakeLog.note(
+ String(
+ format: "bar %@ endsAt %@ duration %@ fraction %.3f lapsed %@ drains %@",
+ snapshot.phaseTitle,
+ snapshot.phaseEndsAtMs.map { String(format: "%.0f", $0) } ?? "nil",
+ snapshot.phaseDurationMs.map(String.init) ?? "nil",
+ fraction,
+ lapsed ? "yes" : "no",
+ drainsFill ? "yes" : "no"
+ )
+ )
+ #endif
+
+ guard let endsAt = snapshot.phaseEndsAt else { return }
+ let remaining = endsAt.timeIntervalSince(now)
+ guard remaining > 0 else { return }
+
+ if drainsFill {
+ await Task.yield()
+ withAnimation(.linear(duration: remaining)) {
+ remainingFraction = 0
+ }
+ }
+
+ // Retiring the deadline is one wake per phase, not per frame, so it stays
+ // in place while dimmed. Losing it would leave a countdown asserting a
+ // claim the phone has stopped confirming.
+ do {
+ try await Task.sleep(for: .seconds(remaining))
+ } catch {
+ return
+ }
+ guard !Task.isCancelled else { return }
+ deadlineLapsed = true
+ }
+
+ private struct PhaseAnimationKey: Hashable {
+ // The drain depicts time, not its caption. Skip mode can change the title
+ // against the same timer; including it here would cancel, snap and restart
+ // an otherwise continuous bar every time the label flips.
+ let endsAtMs: Double?
+ let durationMs: Int?
+ /// Luminance participates only to stop and resume the drain. It belongs in
+ /// the key so the dim cancels the running animation rather than leaving it
+ /// interpolating against a screen that updates once a minute.
+ let drainsFill: Bool
+ }
+}
+
+extension Comparable {
+ fileprivate func clamped(to limits: ClosedRange) -> Self {
+ min(max(self, limits.lowerBound), limits.upperBound)
+ }
+}
+
+extension View {
+ /// Take a control out of service while the display is dimmed, without taking
+ /// it out of the view tree.
+ ///
+ /// Presence is what must not change: wrapping toolbar items in an `if` alters
+ /// the toolbar's structure, which re-hosts the page and rebuilds everything
+ /// under it — measured on hardware as a full MapKit teardown 0.13 s into
+ /// every dim. Every modifier here is a value change instead.
+ ///
+ /// All three are needed. Opacity hides it, hit testing stops a touch, and
+ /// `disabled` plus `accessibilityHidden` stop an assistive technology
+ /// activating a control nobody can see. The trailing toolbar control can be
+ /// Start, which transmits on one tap with no confirmation step.
+ fileprivate func hiddenWhileDimmed(_ dimmed: Bool) -> some View {
+ opacity(dimmed ? 0 : 1)
+ .allowsHitTesting(!dimmed)
+ .disabled(dimmed)
+ .accessibilityHidden(dimmed)
+ }
+}
+
+/// The approved full-screen phase treatment with cadence chosen independently
+/// from its layout. Always-On updates too slowly to promise seconds, while a
+/// wearer-selected readout at full luminance can let the native timer provide
+/// a precise countdown without a one-second SwiftUI timeline.
+private struct ReadoutPhase: View {
+ let snapshot: WatchSnapshot
+ let isLuminanceReduced: Bool
+
+ @State private var deadlineLapsed: Bool
+
+ init(snapshot: WatchSnapshot, isLuminanceReduced: Bool) {
+ self.snapshot = snapshot
+ self.isLuminanceReduced = isLuminanceReduced
+ _deadlineLapsed = State(
+ initialValue: snapshot.phaseEndsAt.map { $0 <= Date() } ?? false
+ )
+ }
+
+ private var phaseKey: PhaseKey {
+ PhaseKey(
+ endsAtMs: snapshot.phaseEndsAtMs,
+ isLuminanceReduced: isLuminanceReduced
+ )
+ }
+
+ /// **The native timer, in both luminance states. No coarse ladder, no
+ /// timeline.**
+ ///
+ /// This replaced a `TimelineView(.periodic(by: 60))` driving a
+ /// `"<15 sec"` / `"<30 sec"` / `"<1 min"` ladder, whose premise was that "a
+ /// seconds figure can be nearly a minute wrong while watchOS throttles
+ /// Always-On". **On hardware that premise is false**: the seconds in a
+ /// `Text(timerInterval:)` keep updating periodically under Always On, closely
+ /// enough to beat every rung of the ladder — which matters here because
+ /// `autoPingInterval` is 15, 30 or 60 seconds, so every phase lives inside
+ /// the range the ladder was coarsest about.
+ ///
+ /// **The simulator disagrees, and it is wrong.** With the Always On toggle on
+ /// watchOS 26 it renders the same construct as `9:––`, blanking the seconds
+ /// outright. That reading briefly justified keeping the ladder for sub-minute
+ /// phases, on the reasoning that `0:––` says nothing. Adam's wrist says
+ /// otherwise, and the wrist is the authority — this file has been caught by
+ /// simulator-only behaviour more than once.
+ ///
+ /// So the system does the work, more precisely than we did, and the per-dim
+ /// structural swap and the minute wake both go away.
+ var body: some View {
+ phase(at: Date())
+ .task(id: phaseKey) {
+ await trackDeadline()
+ }
+ }
+
+ @ViewBuilder
+ private func phase(at date: Date) -> some View {
+ // The clock as well as the flag. `trackDeadline` is gated to full
+ // luminance — it must not wake a dimmed app — so under Always On the flag
+ // never flips, and only this comparison can retire a deadline the phone has
+ // stopped confirming. Same rule as `isWithinDimmedMapHold`: every render
+ // that actually happens gets the right answer, whenever it happens.
+ let lapsed = deadlineLapsed || (snapshot.phaseEndsAt.map { $0 <= date } ?? false)
+
+ VStack(alignment: .leading, spacing: 2) {
+ // Wrapping is intentional. The longest real phase names need two lines
+ // on 40 mm, and preserving every word matters more than uniform height.
+ Text(snapshot.phaseTitle)
+ .font(.system(size: 18, weight: .bold))
+ .foregroundStyle(.white.opacity(lapsed ? 0.45 : 1))
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if !lapsed, let endsAt = snapshot.phaseEndsAt, endsAt > date {
+ // Marks the figure as an upper bound, which is exactly what it is under
+ // Always On. Measured on a Series 9: the system refreshes this text at
+ // a new phase, then again 12–18 s later, then about every 12 s after
+ // that — so between refreshes it reads high by up to that much.
+ // Remaining time only decreases, so "<" stays true across the whole
+ // gap, and an exact bound beats the ladder this replaced, which
+ // discarded up to 14 s of precision to say less.
+ //
+ // **Concatenation is safe here, and that was checked rather than
+ // assumed.** A probe rendering a plain timer, a concatenated one, and
+ // this exact value-driven form advanced together once the simulator
+ // left Always On. Worth recording that the check nearly read the other
+ // way: in Always On the simulator freezes *all* timer text, including a
+ // plain `Text(timerInterval:)`, so a first look suggested concatenation
+ // had broken the countdown when nothing was updating at all.
+ // The lift is not decoration. "<" is centred on the font's math axis,
+ // which sits below the optical centre of lining digits, so unshifted it
+ // reads low against "0:23" — Adam saw it immediately, and a @2x render
+ // measured it at 3.5 px low.
+ //
+ // **One point, chosen by eye over the geometric optimum.** The same
+ // render put the bounding-box centres closest at 2 pt (−0.5 px, against
+ // +1.5 px at 1 pt), but 2 looked high on the wrist. That is the usual
+ // gap between geometric and optical centring: a chevron is a wide,
+ // pointed glyph and the eye weights its extremes, so squaring the boxes
+ // overshoots. The measurement bounds the answer to 1 or 2; the wrist
+ // picks between them, and it picked 1 — which is also an even 2 device
+ // pixels at @2x.
+ //
+ // Tied to the 24 pt size above; re-measure if that changes.
+ (Text(isLuminanceReduced ? "< " : "").baselineOffset(1)
+ + Text(timerInterval: date...endsAt, countsDown: true))
+ .font(.system(size: 24, weight: .bold).monospacedDigit())
+ .foregroundStyle(.white)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+
+ @MainActor
+ private func trackDeadline() async {
+ let now = Date()
+ var transaction = Transaction()
+ transaction.disablesAnimations = true
+ withTransaction(transaction) {
+ deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false
+ }
+
+ // Never sleeps while dimmed: waking a suspended app to retire a caption
+ // nobody is reading is exactly the cost this readout exists to avoid. The
+ // render-time clock check in `phase(at:)` covers that state instead.
+ guard !isLuminanceReduced, let endsAt = snapshot.phaseEndsAt else { return }
+ let remaining = endsAt.timeIntervalSince(now)
+ guard remaining > 0 else { return }
+ do {
+ try await Task.sleep(for: .seconds(remaining))
+ } catch {
+ return
+ }
+ guard !Task.isCancelled else { return }
+ deadlineLapsed = true
+ }
+
+ private struct PhaseKey: Hashable {
+ let endsAtMs: Double?
+ let isLuminanceReduced: Bool
+ }
+}
+
+// MARK: - Markers
+
+private struct FixPuck: View {
+ let headingDeg: Double?
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .fill(.blue)
+ .frame(width: 10, height: 10)
+ Circle()
+ .stroke(.white, lineWidth: 1.5)
+ .frame(width: 10, height: 10)
+ if let headingDeg {
+ Image(systemName: "location.north.fill")
+ .font(.system(size: 7))
+ .foregroundStyle(.white)
+ .offset(y: -9)
+ .rotationEffect(.degrees(headingDeg))
+ }
+ }
+ }
+}
+
+private struct RepeaterPin: View {
+ let color: Color
+ let highlighted: Bool
+
+ var body: some View {
+ ZStack {
+ if highlighted {
+ Circle()
+ .stroke(color, lineWidth: 1.5)
+ .frame(width: 14, height: 14)
+ }
+ Circle()
+ .fill(color)
+ .frame(width: 7, height: 7)
+ .overlay(Circle().stroke(.black.opacity(0.6), lineWidth: 0.5))
+ }
+ }
+}
+
+#if DEBUG
+/// Wrist-raise timings, written to a file rather than only to the system log.
+///
+/// Two failures pushed this out of `NSLog` alone. `log collect --device-udid`
+/// **cannot reach an Apple Watch** — the watch connects over `localNetwork` and
+/// that path wants a USB-attached device, so it fails with `Device not
+/// configured (6)`. Console.app can stream it, but only live, so a run is lost
+/// if streaming was not already started, and the operator has to copy text back
+/// by hand. A file survives the app being suspended, terminated and relaunched,
+/// and comes back whole:
+///
+/// xcrun devicectl device copy from --device \
+/// --domain-type appDataContainer \
+/// --domain-identifier dev.agessaman.meshmapper.watchkitapp \
+/// --source Documents/wake-timings.log --destination .
+///
+/// Lines still go to `NSLog` as well, so Console.app keeps working for anyone
+/// who prefers watching it live.
+enum WakeLog {
+ private static let name = "wake-timings.log"
+
+ private static var url: URL? {
+ FileManager.default
+ .urls(for: .documentDirectory, in: .userDomainMask)
+ .first?
+ .appendingPathComponent(name)
+ }
+
+ /// Appends one timestamped line. Opened and closed per write on purpose: a
+ /// held handle would not be flushed if watchOS killed the app mid-test, which
+ /// is the one moment this log exists to describe.
+ static func note(_ message: String) {
+ guard WatchSettings.debugLogsWakeTiming else { return }
+ let stamp = Date().timeIntervalSince1970
+ NSLog("[wake-log] %@ at %f", message, stamp)
+
+ guard let url else { return }
+ let line = String(format: "%.4f %@\n", stamp, message)
+ guard let data = line.data(using: .utf8) else { return }
+
+ if let handle = try? FileHandle(forWritingTo: url) {
+ defer { try? handle.close() }
+ _ = try? handle.seekToEnd()
+ try? handle.write(contentsOf: data)
+ } else {
+ try? data.write(to: url)
+ }
+ }
+}
+#endif
diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift
new file mode 100644
index 0000000..5d49818
--- /dev/null
+++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift
@@ -0,0 +1,44 @@
+import SwiftUI
+
+/// Entry point for the MeshMapper watchOS companion app.
+///
+/// The watch is a mirror-and-remote for a session the iPhone owns: the phone
+/// holds the BLE link to the MeshCore device and the GPS fix, and pushes
+/// snapshots over WatchConnectivity. Nothing here drives a session on its own.
+///
+/// The app is a vertical pager over a map, a node list, controls, and a debug
+/// dump, with wrist-local layout preferences in settings.
+@main
+struct MeshMapperWatchApp: App {
+ @State private var client: WatchSessionClient
+ @State private var settings: WatchSettings
+ @Environment(\.scenePhase) private var scenePhase
+
+ /// Built here rather than as two independent property initializers so both
+ /// the environment and the session client hold the *same* `WatchSettings`.
+ /// A second instance would keep its own copy of the haptic preference and
+ /// silence only one of them.
+ init() {
+ let settings = WatchSettings()
+ _settings = State(initialValue: settings)
+ _client = State(initialValue: WatchSessionClient(settings: settings))
+ }
+
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .environment(client)
+ .environment(settings)
+ .onAppear { client.refresh() }
+ // `onAppear` fires once, and watchOS suspends this app for the whole
+ // wrist-down interval, so it cannot speak for a resume. Reconciling
+ // here is what stops a glance landing on state the UI calls stale
+ // while the phone is available; `resume` decides on its own whether
+ // that costs a request.
+ .onChange(of: scenePhase) { previous, phase in
+ guard phase == .active, previous != .active else { return }
+ client.resume()
+ }
+ }
+ }
+}
diff --git a/ios/MeshMapperWatch/NodeListView.swift b/ios/MeshMapperWatch/NodeListView.swift
new file mode 100644
index 0000000..d152980
--- /dev/null
+++ b/ios/MeshMapperWatch/NodeListView.swift
@@ -0,0 +1,152 @@
+import SwiftUI
+
+/// Repeaters that answered the most recent ping, strongest first.
+///
+/// The payload carries up to `MeshMapperWatchWire.maxHeard` rows, but this view
+/// never shrinks type to fit them. It uses standard text styles so the wearer's
+/// watch text-size setting is honoured and scrolls for whatever doesn't fit —
+/// at default size that lands around 5–7 rows on a 45 mm, and at large
+/// accessibility sizes it may be 3. That is the correct outcome, not a bug.
+struct NodeListView: View {
+ @Environment(WatchSessionClient.self) private var client
+
+ private var nodes: [WatchHeardNode] { client.snapshot?.geo.heard ?? [] }
+
+ var body: some View {
+ Group {
+ if nodes.isEmpty {
+ emptyState
+ } else {
+ List {
+ ForEach(nodes) { node in
+ NavigationLink {
+ NodeDetailView(node: node)
+ } label: {
+ NodeRow(node: node)
+ }
+ }
+ }
+ .listStyle(.carousel)
+ }
+ }
+ .opacity(client.isStale ? 0.5 : 1.0)
+ }
+
+ private var emptyState: some View {
+ VStack(spacing: 6) {
+ Image(systemName: "antenna.radiowaves.left.and.right.slash")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ Text("Nothing heard yet")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+/// One repeater.
+///
+/// Two lines: identity and signal on top, context underneath. The SNR dot
+/// repeats the colour information as a number so the row still reads under a
+/// colour-vision palette or in bright sun.
+struct NodeRow: View {
+ let node: WatchHeardNode
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 1) {
+ HStack(spacing: 5) {
+ // Dot carries the ping type, matching the map overlay.
+ Circle()
+ .fill(Color(node.typeColor))
+ .frame(width: 7, height: 7)
+ // The hex path hash is the identity — it is always available and
+ // always unambiguous, which a resolved name is not.
+ Text(node.id)
+ .font(.body.monospaced())
+ Spacer(minLength: 4)
+ if let snr = node.snr {
+ Text(snr, format: .number.precision(.fractionLength(1)))
+ .font(.body.monospacedDigit())
+ .foregroundStyle(node.snrColor.map(Color.init) ?? .primary)
+ }
+ }
+
+ if let subtitle {
+ Text(subtitle)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.vertical, 1)
+ }
+
+ /// Name and distance are both optional: a short path hash may match several
+ /// repeaters, and a matched repeater may not have published a location.
+ private var subtitle: String? {
+ var parts: [String] = []
+ if let name = node.name { parts.append(name) }
+ if let distance = node.distanceLabel { parts.append(distance) }
+ return parts.isEmpty ? nil : parts.joined(separator: " · ")
+ }
+}
+
+struct NodeDetailView: View {
+ let node: WatchHeardNode
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 5) {
+ Circle().fill(Color(node.typeColor)).frame(width: 9, height: 9)
+ Text(node.id)
+ .font(.headline.monospaced())
+ }
+
+ if let name = node.name {
+ Text(name)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ } else {
+ Text("Name unresolved — short path hash")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+
+ if let snr = node.snr {
+ detail("SNR", snr.formatted(.number.precision(.fractionLength(1))) + " dB")
+ }
+ if let distance = node.distanceLabel {
+ detail("Distance", distance)
+ }
+ detail("Heard", node.heardAt.formatted(date: .omitted, time: .shortened))
+ }
+ .padding(.horizontal, 4)
+ }
+ }
+
+ private func detail(_ label: String, _ value: String) -> some View {
+ HStack(alignment: .firstTextBaseline) {
+ Text(label)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 6)
+ Text(value)
+ .font(.caption)
+ .multilineTextAlignment(.trailing)
+ }
+ }
+}
+
+extension WatchHeardNode {
+ var heardAt: Date { Date(timeIntervalSince1970: atMs / 1000) }
+
+ var distanceLabel: String? {
+ guard let distanceM else { return nil }
+ if distanceM < 1000 {
+ return "\(Int(distanceM.rounded())) m"
+ }
+ return (distanceM / 1000).formatted(.number.precision(.fractionLength(1))) + " km"
+ }
+}
diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift
new file mode 100644
index 0000000..485416d
--- /dev/null
+++ b/ios/MeshMapperWatch/SampleSnapshot.swift
@@ -0,0 +1,283 @@
+#if DEBUG
+import Foundation
+
+/// Synthetic state for design work and headless verification.
+///
+/// The simulator has no Bluetooth, so a paired simulator pair can never
+/// produce pings or repeaters — the map would always be empty there. Enable
+/// with a launch argument:
+///
+/// xcrun simctl launch net.meshmapper.app.watchkitapp -MeshMapperSampleData YES
+///
+/// Other DEBUG launch arguments used by the capture harness:
+///
+/// - `-MeshMapperSamplePhase listen|wait|lapsed` exercises phase timing.
+/// - `-MeshMapperSampleControls active|idle|blocked|cooldown|passiveOnly|txActive`
+/// exercises every toolbar slot state.
+/// - `-MeshMapperLongIds YES` exercises six-character path hashes.
+/// - `-MeshMapperShowNodeSheet YES` opens the heard-node sheet.
+/// - `-MeshMapperInitialPage ` opens a specific vertical page.
+/// - `-MeshMapperForceDimmed YES` renders the reduced-luminance readout.
+/// - `-MeshMapperForceRefusal ` presents the failure banner; capture
+/// within six seconds because it deliberately uses the production expiry.
+/// - `-MeshMapperSampleWalk YES` advances the sample fix by 15 m every 11 s,
+/// which is what the phone's movement gate and walking pace actually deliver.
+/// Without it the fix never moves and the follow camera has nothing to
+/// animate, so this is the only way to review that motion off a wrist.
+/// Pass a number instead of `YES` to set the interval — the simulator stops
+/// redrawing a few seconds after launch, so a capture run needs steps far
+/// closer together than a walk delivers. See `walkStepInterval`.
+/// - `-MeshMapperAutoPageTo ` switches pages five seconds after launch.
+/// The simulator cannot swipe, and page transitions are a real defect
+/// surface: a sheet raised from the map once survived onto the next page,
+/// blurring it and swallowing every swipe, which reads exactly like a crash.
+/// - `-MeshMapperTimeSwitchToMap YES` flips the main page to the map five
+/// seconds after launch and logs `[switch] requesting map at `, so the
+/// MapKit rebuild a wrist raise pays can be timed against screenshot mtimes.
+/// Always pass `-layout.mainPageContent readout` with it: starting on the map
+/// makes the flip a no-op that still logs, which looks like a fast result.
+/// It also logs `[span] rendered … requested … confirmed …` on every camera
+/// callback, which is how to check on hardware whether a rebuilt map ever
+/// reports a region that is not the one we asked for. In the simulator it
+/// never does — three teardown cycles, 0.0% drift every time.
+///
+/// Listening and active remain the defaults so existing capture commands keep
+/// their behaviour.
+///
+/// Every affordance is DEBUG-only and requires its explicit argument, so none
+/// can leak into a shipping build or mask a real transport failure.
+enum SampleSnapshot {
+ static var isEnabled: Bool {
+ UserDefaults.standard.bool(forKey: "MeshMapperSampleData")
+ }
+
+ /// Downtown Seattle, roughly — somewhere with enough spread to see marker
+ /// density and line geometry at a realistic zoom.
+ private static let originLat = 47.6062
+ private static let originLon = -122.3321
+
+ /// Metres travelled per emitted step, matching the phone's movement gate.
+ ///
+ /// `WatchWire.minMoveMeters` is 15, so a walking wearer's fix advances in 15 m
+ /// jumps rather than continuously. Reproducing the *step size* is the whole
+ /// point of this harness: a smaller one would make the follow animation look
+ /// fine for reasons the real wire does not provide.
+ static let walkStepMeters: Double = 15
+
+ /// Seconds between steps. Defaults to 15 m at an ordinary walking pace
+ /// (~1.4 m/s); `-MeshMapperSampleWalk ` overrides it.
+ ///
+ /// **The override exists because the simulator's display stops updating a few
+ /// seconds after launch** — measured at ~4.5 s, after which every screenshot
+ /// is the same frame and even a `Text(timerInterval:)` sits frozen. That is
+ /// the screen sleeping, not the app dimming: the readout never replaces the
+ /// map, so the dim hold plainly did not run. A capture run therefore has only
+ /// a few seconds of live rendering, and a real 11 s cadence puts zero steps
+ /// inside it. Step size is what the follow animation actually has to smooth,
+ /// so shortening the interval costs the review nothing.
+ static var walkStepInterval: TimeInterval {
+ let override = UserDefaults.standard.double(forKey: "MeshMapperSampleWalk")
+ return override > 0 ? override : 11
+ }
+
+ /// Whether to walk at all. The interval override doubles as the switch, so
+ /// `YES` reads as "walk at the real cadence" and a number as "walk this fast".
+ static var isWalking: Bool {
+ UserDefaults.standard.double(forKey: "MeshMapperSampleWalk") > 0
+ || UserDefaults.standard.bool(forKey: "MeshMapperSampleWalk")
+ }
+
+ /// Course held while walking, in degrees clockwise from north. Matches the
+ /// stationary sample's `headingDeg` so the puck glyph does not swing when the
+ /// harness is switched on.
+ private static let walkBearingDeg: Double = 72
+
+ static func make(stepsWalked: Int = 0) -> WatchSnapshot {
+ let now = Date().timeIntervalSince1970 * 1000
+
+ // Flat-earth offset, which at these distances is exact to well under a
+ // metre and keeps the harness free of a projection.
+ let travelled = Double(stepsWalked) * walkStepMeters
+ let bearing = walkBearingDeg * .pi / 180
+ let walkLat = travelled * cos(bearing) / 111_320
+ let walkLon = travelled * sin(bearing)
+ / (111_320 * cos((originLat + 0.006) * .pi / 180))
+
+ let samplePhase: (name: String, title: String, endsAtMs: Double, durationMs: Int)
+ switch UserDefaults.standard.string(forKey: "MeshMapperSamplePhase") {
+ case "wait":
+ samplePhase = ("waiting", "Next ping", now + 25_000, 30_000)
+ case "lapsed":
+ samplePhase = ("listening", "Listening…", now - 5_000, 60_000)
+ default:
+ samplePhase = ("listening", "Listening…", now + 42_000, 60_000)
+ }
+
+ let sampleControlState = UserDefaults.standard.string(
+ forKey: "MeshMapperSampleControls"
+ )
+ let sampleControls: WatchControls
+ switch sampleControlState {
+ case "idle":
+ sampleControls = WatchControls(
+ canStartStop: true,
+ canManualPing: false,
+ isSessionActive: false,
+ manualPingApplicable: true,
+ manualCooldownEndsAtMs: nil,
+ blockedReason: nil
+ )
+ case "blocked":
+ sampleControls = WatchControls(
+ canStartStop: false,
+ canManualPing: false,
+ isSessionActive: false,
+ manualPingApplicable: false,
+ manualCooldownEndsAtMs: nil,
+ blockedReason: "This zone is currently passive-only"
+ )
+ case "cooldown":
+ // The one unavailability the phone reports with no `blockedReason`.
+ sampleControls = WatchControls(
+ canStartStop: true,
+ canManualPing: false,
+ isSessionActive: true,
+ manualPingApplicable: true,
+ manualCooldownEndsAtMs: now + 12_000,
+ blockedReason: nil
+ )
+ case "passiveOnly":
+ sampleControls = WatchControls(
+ canStartStop: true,
+ canManualPing: false,
+ isSessionActive: true,
+ manualPingApplicable: false,
+ manualCooldownEndsAtMs: nil,
+ blockedReason: "Passive Only"
+ )
+ case "txActive":
+ sampleControls = WatchControls(
+ canStartStop: true,
+ canManualPing: false,
+ isSessionActive: true,
+ manualPingApplicable: false,
+ manualCooldownEndsAtMs: nil,
+ blockedReason: nil
+ )
+ default:
+ sampleControls = WatchControls(
+ canStartStop: true,
+ canManualPing: true,
+ isSessionActive: true,
+ manualPingApplicable: true,
+ manualCooldownEndsAtMs: nil,
+ blockedReason: nil
+ )
+ }
+
+ let green = WatchColor(r: 0.30, g: 0.69, b: 0.31)
+ let red = WatchColor(r: 0.96, g: 0.26, b: 0.21)
+ let purple = WatchColor(r: 0.49, g: 0.33, b: 0.78)
+ let pink = WatchColor(r: 0.84, g: 0.20, b: 0.52)
+ let orange = WatchColor(r: 0.99, g: 0.49, b: 0.08)
+
+ // A drive west-to-east with a mix of answered and unanswered pings.
+ let pings: [WatchPing] = (0..<40).map { i in
+ let answered = i % 3 != 0
+ return WatchPing(
+ id: "p\(i)",
+ lat: originLat + Double(i) * 0.00035 + (i % 2 == 0 ? 0.0002 : -0.0002),
+ lon: originLon + Double(i) * 0.00085,
+ kind: i % 5 == 0 ? "rx" : "tx",
+ color: i % 5 == 0 ? purple : (answered ? green : red),
+ atMs: now - Double(40 - i) * 20_000
+ )
+ }
+
+ let repeaters: [WatchRepeater] = [
+ ("4E", "Capitol Hill", 0.010, 0.004, pink, true),
+ ("77", "Queen Anne", -0.006, 0.012, pink, true),
+ ("A2", "Beacon Hill", -0.012, -0.008, orange, false),
+ ("B9", "Magnolia", 0.004, -0.015, pink, false),
+ ].map { id, name, dLat, dLon, color, heard in
+ WatchRepeater(
+ id: id,
+ hexId: id,
+ name: name,
+ lat: originLat + dLat,
+ lon: originLon + dLon,
+ color: color,
+ heardThisCycle: heard
+ )
+ }
+
+ // Mirrors the phone's "Top Heard": three rows from the latest ping, then
+ // the RX slot in purple. Names are deliberately mixed — a 4-char hash
+ // resolves, a 2-char one often cannot.
+ let teal = WatchColor(r: 0.32, g: 0.83, b: 0.91)
+ // Pass -MeshMapperLongIds YES to render the 3-byte-zone worst case: six
+ // hex characters per row, which is what the box has to survive on 40 mm.
+ let long = UserDefaults.standard.bool(forKey: "MeshMapperLongIds")
+ let heard: [WatchHeardNode] = [
+ (long ? "4E5D82" : "4E5D", "Capitol Hill", 8.5, 1420.0, green),
+ (long ? "77A1B0" : "77A1", nil, 2.25, 2310.0, teal),
+ (long ? "A2FF31" : "A2", nil, -14.5, nil, green),
+ (long ? "B914C2" : "B914", "Magnolia", 5.75, 2870.0, purple),
+ ].map { id, name, snr, distance, typeColor in
+ WatchHeardNode(
+ id: id,
+ name: name,
+ snr: snr,
+ atMs: now - 30_000,
+ distanceM: distance,
+ snrColor: snr > 5
+ ? WatchColor(r: 0.30, g: 0.69, b: 0.31)
+ : (snr > -1
+ ? WatchColor(r: 1.0, g: 0.60, b: 0.0)
+ : WatchColor(r: 0.96, g: 0.26, b: 0.21)),
+ typeColor: typeColor
+ )
+ }
+
+ return WatchSnapshot(
+ wireVersion: MeshMapperWatchWire.version,
+ sessionId: "sample",
+ // Ping can own the active-session slot only during Passive monitoring;
+ // Active and Hybrid are TX modes and keep Stop reachable instead.
+ mode: sampleControlState == "txActive" ? "Hybrid" : "Passive",
+ phase: samplePhase.name,
+ phaseTitle: samplePhase.title,
+ phaseDetail: "Waiting for echoes",
+ phaseEndsAtMs: samplePhase.endsAtMs,
+ phaseDurationMs: samplePhase.durationMs,
+ isConnected: true,
+ zoneCode: "SEA",
+ txCount: 27,
+ rxCount: 14,
+ discoveryCount: 6,
+ traceCount: 0,
+ queueSize: 2,
+ pingColor: green,
+ availableStartModes: sampleControlState == "passiveOnly"
+ ? ["passive"]
+ : ["passive", "hybrid"],
+ geo: WatchGeo(
+ you: WatchPosition(
+ lat: originLat + 0.006 + walkLat,
+ lon: originLon + 0.014 + walkLon,
+ headingDeg: walkBearingDeg,
+ accuracyM: 8,
+ fixedAtMs: now
+ ),
+ pings: pings,
+ repeaters: repeaters,
+ heard: heard,
+ linkedRepeaterIds: ["4E", "77"]
+ ),
+ controls: sampleControls,
+ cue: nil,
+ updatedAtMs: now
+ )
+ }
+}
+#endif
diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift
new file mode 100644
index 0000000..d849747
--- /dev/null
+++ b/ios/MeshMapperWatch/SettingsPage.swift
@@ -0,0 +1,90 @@
+import SwiftUI
+
+/// Wrist-side layout and map preferences.
+///
+/// Deliberately small. Anything about the session itself belongs on the phone,
+/// which owns the radio and the guards.
+struct SettingsPage: View {
+ @Environment(WatchSettings.self) private var settings
+ @Environment(WatchSessionClient.self) private var client
+
+ private var availableStartModes: [WatchSettings.DefaultStartMode] {
+ let available = client.snapshot?.availableStartModes ?? ["passive"]
+ return WatchSettings.DefaultStartMode.allCases.filter {
+ $0 == .passive || available.contains($0.rawValue)
+ }
+ }
+
+ private var effectiveStartMode: WatchSettings.DefaultStartMode {
+ settings.effectiveStartMode(
+ availableStartModes: client.snapshot?.availableStartModes
+ )
+ }
+
+ var body: some View {
+ @Bindable var settings = settings
+
+ List {
+ Section("Display") {
+ Picker("Main page", selection: $settings.mainPageContent) {
+ ForEach(WatchSettings.MainPageContent.allCases) { content in
+ Text(content.label).tag(content)
+ }
+ }
+ Picker("Node list", selection: $settings.nodeListPlacement) {
+ ForEach(WatchSettings.NodeListPlacement.allCases) { placement in
+ Text(placement.label).tag(placement)
+ }
+ }
+ }
+
+ Section("Map") {
+ Toggle("Follow position", isOn: $settings.follow)
+ Toggle("Lines to repeaters", isOn: $settings.showLinks)
+ }
+
+ Section("Controls") {
+ Picker(
+ "Default start mode",
+ selection: Binding(
+ get: { effectiveStartMode },
+ set: { settings.defaultStartMode = $0 }
+ )
+ ) {
+ ForEach(availableStartModes) { mode in
+ Text(mode.label).tag(mode)
+ }
+ }
+
+ if settings.defaultStartMode != effectiveStartMode {
+ Text("Hybrid unavailable here; Start uses Passive")
+ .foregroundStyle(WatchPalette.tertiary)
+ }
+
+ Toggle(
+ "When available, show ping option",
+ isOn: $settings.showPingWhenAvailable
+ )
+
+ // Sits with Controls because the only cue the phone sends is the
+ // failure of a control the wearer used. It is the wearer's own wrist,
+ // so the switch stays on the wrist: silencing it must not depend on
+ // the phone link that just failed.
+ Toggle("Haptic feedback", isOn: $settings.haptics)
+ }
+
+ #if DEBUG
+ // Not a preference — an A/B switch for an Instruments trace, so it never
+ // reaches Release. Takes effect from the next phase rather than mid-drain.
+ Section("Instruments") {
+ Toggle("Freeze timer bar", isOn: $settings.freezesTimerBar)
+ // Persisted, unlike the launch argument this replaced — a wrist-down
+ // test invites watchOS to relaunch the app, which silently emptied
+ // `NSArgumentDomain` and produced an empty log with no clue why.
+ Toggle("Log wake timing", isOn: $settings.logsWakeTiming)
+ }
+ #endif
+ }
+ .font(.caption)
+ }
+}
diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift
new file mode 100644
index 0000000..7118cc5
--- /dev/null
+++ b/ios/MeshMapperWatch/WatchSessionClient.swift
@@ -0,0 +1,593 @@
+import Foundation
+import SwiftUI
+import WatchConnectivity
+import WatchKit
+
+/// Receives snapshots from the iPhone and sends intents back.
+///
+/// The watch never decides anything: it renders what the phone sent and asks
+/// for what the wearer tapped. The phone owns the BLE link, the GPS fix, and
+/// every guard around transmitting.
+///
+/// **Main-actor isolated, because everything it stores is rendered.**
+/// WatchConnectivity documents that "the methods of this protocol are called on
+/// a background thread of your app", and every stored property here is read by
+/// SwiftUI through `@Observable`. The delegate conformance below is therefore
+/// `nonisolated` and hops explicitly; see the note on the extension for what
+/// deliberately stays off the main actor.
+@Observable
+@MainActor
+final class WatchSessionClient: NSObject {
+ /// Read for exactly one decision: whether a cue may play a haptic.
+ ///
+ /// Held rather than resolved from `UserDefaults` here so the preference has
+ /// one owner. Two surfaces resolving the same preference independently is
+ /// the failure `effectiveStartMode` exists to prevent, and a silenced watch
+ /// that still buzzes would be the same class of bug.
+ @ObservationIgnored private let settings: WatchSettings
+
+ init(settings: WatchSettings) {
+ self.settings = settings
+ super.init()
+ }
+
+ /// Latest state from the phone, or nil before the first delivery.
+ private(set) var snapshot: WatchSnapshot?
+
+ /// When the last snapshot arrived — drives the stale badge.
+ private(set) var receivedAt: Date?
+
+ /// Stored rather than derived from `Date()`: time passing does not invalidate
+ /// a SwiftUI observation by itself, so the boundary has to become an event.
+ private(set) var isStale = true
+ private var staleBoundaryTask: Task?
+
+ /// The phone's explanation for a refused admission or a later failed action.
+ /// Both belong to one short-lived presentation path on the controls page.
+ private(set) var lastRefusal: String?
+
+ /// Explicit attribution lets Controls place feedback beside the action it
+ /// explains without guessing that an unrelated phone event belongs to the
+ /// last thing tapped. It never affects admission, transport, or the command.
+ private(set) var lastRefusalCommand: WatchCommand.Kind?
+
+ /// A refusal explains one completed tap, not the current transport state.
+ /// Restarting its lifetime on replacement prevents an older expiry from
+ /// erasing newer feedback that happens to arrive near the same moment.
+ private var refusalExpiryTask: Task?
+
+ /// Immediate messages and application context can deliver the same cue in
+ /// either order. IDs make that transport redelivery one visible event.
+ private var presentedCueIDs = Set()
+ private var presentedCueIDOrder = [String]()
+
+ /// Wearer-initiated command awaiting evidence of phone-side progress.
+ /// Automatic refreshes stay out because they have no corresponding wrist
+ /// action, and queued delivery has no acknowledgement to wait for.
+ private(set) var pendingCommand: WatchCommand.Kind?
+ private var pendingCommandTimeoutTask: Task?
+
+ /// Set when a payload arrives from a wire version this build predates.
+ private(set) var versionMismatch = false
+
+ /// Whether the currently rendered surface needs map-only geography.
+ /// Launches begin true: an unnecessary full payload is preferable to a map
+ /// that opens blank before this process has described its current surface.
+ private var mapGeoNeeded = true
+ private var lastSentMapGeoNeeded: Bool?
+ private var mapGeoSuppressionTask: Task?
+ private var mapGeoRenewalTask: Task?
+ private var lastMapGeoRecoveryRequestAt: Date?
+
+ private var session: WCSession? {
+ WCSession.isSupported() ? WCSession.default : nil
+ }
+
+ /// Mirrors `WCSession.isReachable`, stored rather than computed.
+ ///
+ /// Reading `WCSession.default.isReachable` inside a computed property takes
+ /// no observation dependency, so a view rendering it was never invalidated
+ /// when reachability changed — it showed whatever happened to be true at the
+ /// last unrelated render. `sessionReachabilityDidChange` is the callback that
+ /// turns the change into an event, exactly as its documentation intends.
+ private(set) var isReachable = false
+
+ /// A snapshot older than this is shown greyed with an age badge. The phone
+ /// only sends on real change, so silence is normal — this threshold is
+ /// about "the phone has probably gone away", not "no update recently".
+ static let staleAfter: TimeInterval = 90
+ private static let cueFreshFor: TimeInterval = 30
+ /// Phone and watch clocks normally agree to well under a second. This is the
+ /// slack allowed anywhere a phone-stamped time is compared against watch
+ /// "now", and mirrors the tolerance the phone applies to watch commands.
+ private static let clockTolerance: TimeInterval = 5
+ private static let mapGeoSuppressionDelay: TimeInterval = 15
+ private static let mapGeoRenewalInterval: TimeInterval = 5 * 60
+ private static let mapGeoRecoveryThrottle: TimeInterval = 3
+
+ /// Bring the session up and pull a current snapshot.
+ ///
+ /// Activation is asynchronous, so a refresh requested before it completes is
+ /// deferred to the activation callback rather than failing as "unreachable".
+ func refresh() {
+ #if DEBUG
+ if SampleSnapshot.isEnabled {
+ snapshot = SampleSnapshot.make()
+ markSnapshotReceived()
+ startSampleWalkIfRequested()
+ return
+ }
+ #endif
+
+ guard let session else { return }
+ session.delegate = self
+
+ if session.activationState == .activated {
+ // An already-activated session fires no activation callback, so this is
+ // the only chance to seed reachability before the first render.
+ noteReachability(session.isReachable)
+ ingest(context: session.receivedApplicationContext)
+ requestFullSnapshot()
+ if !mapGeoNeeded { scheduleMapGeoSuppression() }
+ return
+ }
+
+ pendingRefresh = true
+ session.activate()
+ }
+
+ /// Reconcile after the scene becomes active again.
+ ///
+ /// `onAppear` is not a resume callback — it fires once — 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. So without this a
+ /// wearer could raise their wrist onto state the UI itself calls stale and
+ /// have nothing ask the phone to prove otherwise.
+ ///
+ /// Deliberately not `refresh()`. That always requests, which would put a
+ /// WatchConnectivity round trip behind every glance — the opposite of what
+ /// this transport is built for. Ingesting the retained context is free, so
+ /// do it always; spend the radio only when what we hold is stale or missing,
+ /// which is exactly when a request can change what the wearer sees.
+ func resume() {
+ #if DEBUG
+ if SampleSnapshot.isEnabled { return }
+ #endif
+
+ guard let session else { return }
+ session.delegate = self
+
+ guard session.activationState == .activated else {
+ pendingRefresh = true
+ session.activate()
+ return
+ }
+
+ // A reachability change during the suspension has no delegate callback to
+ // deliver, so re-read it alongside the retained context.
+ noteReachability(session.isReachable)
+ ingest(context: session.receivedApplicationContext)
+
+ // Read after ingesting: a context retained while the wrist was down may
+ // have just answered the question, and then the radio is not needed.
+ let needsPhone = snapshot == nil || isStale
+ #if DEBUG
+ // The whole claim of this method in one line per glance. A wrist-raise run
+ // should show `resume-local` for fresh glances and exactly one
+ // `resume-request` for the first glance past the stale boundary.
+ WakeLog.note(needsPhone ? "resume-request" : "resume-local")
+ #endif
+ if needsPhone { requestFullSnapshot() }
+
+ if !mapGeoNeeded { scheduleMapGeoSuppression() }
+ }
+
+ private var pendingRefresh = false
+
+ // MARK: - Commands
+
+ /// - Parameter silent: suppress the refusal banner. Used for the automatic
+ /// refresh, which the wearer never asked for and shouldn't see fail.
+ /// Queues without pre-checking `isReachable`.
+ ///
+ /// Wrist controls normally run while the phone app is not foregrounded,
+ /// where `sendMessage` can execute the command yet fail its reply as
+ /// undeliverable. One queued path avoids both that false failure and the
+ /// duplicate-transmit risk of retrying an ambiguously delivered message.
+ func send(
+ _ kind: WatchCommand.Kind,
+ mode: String? = nil,
+ mapGeoNeeded: Bool? = nil,
+ forceRefresh: Bool = false,
+ silent: Bool = false
+ ) {
+ guard let session, session.activationState == .activated else {
+ if !silent {
+ setLastRefusal("Not connected to iPhone", from: kind)
+ }
+ return
+ }
+
+ let command = WatchCommand(
+ kind: kind,
+ mode: mode,
+ mapGeoNeeded: mapGeoNeeded,
+ forceRefresh: forceRefresh ? true : nil,
+ id: UUID().uuidString,
+ issuedAtMs: Date().timeIntervalSince1970 * 1000
+ )
+ guard let data = try? MeshMapperWatchWire.encoder.encode(command),
+ let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else {
+ if !silent {
+ setLastRefusal("Could not encode command", from: kind)
+ }
+ return
+ }
+
+ if !silent {
+ setLastRefusal(nil, from: kind)
+ beginPending(kind)
+ }
+
+ session.transferUserInfo([MeshMapperWatchWire.commandKey: dict])
+ }
+
+ /// Report whether the current surface needs its expensive marker payload.
+ /// Returning to the map is immediate; suppression waits out short wrist-down
+ /// transitions so ordinary glances do not enqueue a false/true pair.
+ func setMapGeoNeeded(_ needed: Bool) {
+ mapGeoNeeded = needed
+ mapGeoSuppressionTask?.cancel()
+ mapGeoSuppressionTask = nil
+
+ if needed {
+ mapGeoRenewalTask?.cancel()
+ mapGeoRenewalTask = nil
+ sendMapGeoPreference(true)
+ } else {
+ scheduleMapGeoSuppression()
+ }
+ }
+
+ private func scheduleMapGeoSuppression() {
+ mapGeoSuppressionTask?.cancel()
+ mapGeoSuppressionTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(Self.mapGeoSuppressionDelay))
+ guard !Task.isCancelled, let self, !self.mapGeoNeeded else { return }
+ self.mapGeoSuppressionTask = nil
+ self.sendMapGeoPreference(false)
+ self.scheduleMapGeoRenewal()
+ }
+ }
+
+ private func scheduleMapGeoRenewal() {
+ mapGeoRenewalTask?.cancel()
+ mapGeoRenewalTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(Self.mapGeoRenewalInterval))
+ guard !Task.isCancelled, let self, !self.mapGeoNeeded else { return }
+ self.mapGeoRenewalTask = nil
+ // The phone treats suppression as a lease. Renewal keeps a long
+ // Always-On session cheap; if this task stops, the lease expires back
+ // to full geography rather than leaving a future map blank.
+ self.sendMapGeoPreference(false, force: true)
+ self.scheduleMapGeoRenewal()
+ }
+ }
+
+ /// - Parameter force: resend even when the phone was already told this value,
+ /// which is what renews the lease.
+ /// - Parameter refresh: additionally demand a snapshot back. Lease traffic
+ /// leaves this false so an unchanged session stays silent.
+ private func sendMapGeoPreference(
+ _ needed: Bool,
+ force: Bool = false,
+ refresh: Bool = false
+ ) {
+ guard force || lastSentMapGeoNeeded != needed else { return }
+ guard let session, session.activationState == .activated else { return }
+ lastSentMapGeoNeeded = needed
+ send(
+ .requestSnapshot,
+ mapGeoNeeded: needed,
+ forceRefresh: refresh,
+ silent: true
+ )
+ }
+
+ private func requestFullSnapshot() {
+ // Activation always starts from the safe assumption even if a retained
+ // application context says the previous process had suppressed its map.
+ //
+ // This is the one caller that genuinely needs state back: whatever the
+ // watch holds came from a retained context of unknown age, so an unchanged
+ // session must still answer rather than dedupe into silence.
+ sendMapGeoPreference(true, force: true, refresh: true)
+ }
+
+ private func beginPending(_ kind: WatchCommand.Kind) {
+ pendingCommandTimeoutTask?.cancel()
+ pendingCommand = kind
+ pendingCommandTimeoutTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(10))
+ guard !Task.isCancelled, self?.pendingCommand == kind else { return }
+ self?.pendingCommand = nil
+ self?.pendingCommandTimeoutTask = nil
+ }
+ }
+
+ private func clearPendingCommand() {
+ pendingCommandTimeoutTask?.cancel()
+ pendingCommandTimeoutTask = nil
+ pendingCommand = nil
+ }
+
+ #if DEBUG
+ /// Seed a refusal so the failure banner can be captured headlessly.
+ ///
+ /// The banner is otherwise unreachable in the simulator: producing one needs
+ /// a command to be refused, and there is no way to tap a control there. It
+ /// expires on the usual six-second schedule, so a capture must be taken
+ /// inside that window — a screenshot at eight seconds shows an empty screen
+ /// and reads exactly like a broken banner.
+ func debugForceRefusal(_ message: String) {
+ setLastRefusal(message, from: .startSession)
+ }
+ #endif
+
+ private func setLastRefusal(
+ _ refusal: String?,
+ from command: WatchCommand.Kind?
+ ) {
+ refusalExpiryTask?.cancel()
+ refusalExpiryTask = nil
+ lastRefusal = refusal
+ lastRefusalCommand = refusal == nil ? nil : command
+
+ guard refusal != nil else { return }
+ refusalExpiryTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(6))
+ guard !Task.isCancelled else { return }
+ self?.lastRefusal = nil
+ self?.lastRefusalCommand = nil
+ self?.refusalExpiryTask = nil
+ }
+ }
+
+ /// - Parameter producedAt: when the phone built the payload. Absent for the
+ /// debug sample, which is generated on the wrist.
+ private func markSnapshotReceived(at arrival: Date = Date(), producedAt: Date? = nil) {
+ staleBoundaryTask?.cancel()
+ staleBoundaryTask = nil
+
+ // Age from when the phone built the payload rather than when it reached the
+ // wrist. Launch ingests whatever application context WatchConnectivity
+ // retained, which can be hours old, and stamping arrival there would
+ // present long-dead state as fresh for a full 90 seconds.
+ //
+ // 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 genuinely live payload early.
+ let origin =
+ producedAt.map { min(arrival, $0.addingTimeInterval(Self.clockTolerance)) } ?? arrival
+ receivedAt = origin
+
+ let remaining = Self.staleAfter - arrival.timeIntervalSince(origin)
+ guard remaining > 0 else {
+ isStale = true
+ return
+ }
+ isStale = false
+
+ // One task per delivery makes the 90-second boundary observable without a
+ // polling timer. A newer snapshot cancels this task and owns the next one.
+ staleBoundaryTask = Task { @MainActor [weak self] in
+ try? await Task.sleep(for: .seconds(remaining))
+ guard !Task.isCancelled, self?.receivedAt == origin else { return }
+ self?.isStale = true
+ self?.staleBoundaryTask = nil
+ }
+ }
+
+ private static func isFresh(_ cue: WatchHapticCue, at arrival: Date) -> Bool {
+ guard let issuedAt = cue.issuedAt else { return false }
+ let age = arrival.timeIntervalSince(issuedAt)
+ // A few seconds of skew must not suppress a real failure that has just
+ // crossed the radio.
+ return age >= -clockTolerance && age <= cueFreshFor
+ }
+
+ /// The main-actor half of the activation callback.
+ private func completeActivation() {
+ guard pendingRefresh else { return }
+ pendingRefresh = false
+ requestFullSnapshot()
+ if !mapGeoNeeded { scheduleMapGeoSuppression() }
+ }
+
+ private func noteReachability(_ reachable: Bool) {
+ guard isReachable != reachable else { return }
+ isReachable = reachable
+ }
+
+ #if DEBUG
+ /// Advance the sample fix so the follow camera has something to track.
+ ///
+ /// The stationary sample snapshot can show that the map *renders*, never that
+ /// it moves well — and motion is the one thing the follow animation exists
+ /// for. This walks the fix at the real wire's step size and cadence rather
+ /// than smoothly, because a continuous feed would hide exactly the jump being
+ /// smoothed.
+ ///
+ /// Idempotent: `refresh()` runs on every scene activation and must not leave
+ /// a second walker behind, which would double the pace.
+ private func startSampleWalkIfRequested() {
+ guard sampleWalkTask == nil, SampleSnapshot.isWalking else { return }
+
+ let interval = SampleSnapshot.walkStepInterval
+ sampleWalkTask = Task { @MainActor [weak self] in
+ var steps = 0
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(interval))
+ guard !Task.isCancelled, let self else { return }
+ steps += 1
+ self.snapshot = SampleSnapshot.make(stepsWalked: steps)
+ self.markSnapshotReceived()
+ }
+ }
+ }
+
+ /// Not observed: nothing renders the walker, and letting it invalidate views
+ /// would make the harness a source of the redraws it is meant to measure.
+ @ObservationIgnored private var sampleWalkTask: Task?
+ #endif
+
+ /// Let the wearer feel a cue the phone raised.
+ ///
+ /// **This only fires while watchOS is actually running this app.** The system
+ /// ignores `play(_:)` from a suspended or background app, so a failure that
+ /// lands during a long wrist-down is still seen rather than felt. The case it
+ /// answers is the real one: a wearer taps Ping or Start, gets an accepted
+ /// ack, glances away, and the command fails a moment later with the app
+ /// still frontmost. Before this the only report was a banner on a screen the
+ /// wearer had stopped reading.
+ ///
+ /// Unknown kinds still play. The wire calls this "a one-shot event the watch
+ /// should feel", so an older watch meeting a newer phone should err toward
+ /// the generic notification rather than toward silence.
+ private func play(_ cue: WatchHapticCue) {
+ guard settings.haptics else { return }
+ let type: WKHapticType
+ switch cue.kind {
+ case "success": type = .success
+ case "failure": type = .failure
+ default: type = .notification
+ }
+ WKInterfaceDevice.current().play(type)
+ }
+
+ // MARK: - Ingest
+
+ /// `nonisolated` on purpose: decoding is the expensive part, it needs no
+ /// isolation, and WatchConnectivity already hands it to us on a background
+ /// thread. Only `apply` crosses onto the main actor.
+ private nonisolated func ingest(context: [String: Any]) {
+ guard let data = context[MeshMapperWatchWire.payloadKey] as? Data else { return }
+ ingest(data: data)
+ }
+
+ private nonisolated func ingest(data: Data) {
+ guard let decoded = try? MeshMapperWatchWire.decoder.decode(WatchSnapshot.self, from: data)
+ else {
+ return
+ }
+
+ // Refuse rather than render a payload whose fields may have changed
+ // meaning — a wrong reading on the wrist is worse than a blank one.
+ guard decoded.isSupportedVersion else {
+ Task { @MainActor [weak self] in self?.versionMismatch = true }
+ return
+ }
+
+ Task { @MainActor [weak self] in self?.apply(decoded) }
+ }
+
+ private func apply(_ decoded: WatchSnapshot) {
+ let arrival = Date()
+ versionMismatch = false
+ snapshot = decoded
+ markSnapshotReceived(at: arrival, producedAt: decoded.updatedAt)
+ // A queued command has no ack. Any subsequent snapshot proves the phone
+ // has resumed communicating; a separate timeout covers the case where
+ // state dedupe means no snapshot follows.
+ clearPendingCommand()
+
+ if mapGeoNeeded && !decoded.mapGeoIncluded {
+ // updateApplicationContext is latest-state-wins, but a context sent
+ // just before the map reappeared may still win the delivery race. The
+ // empty arrays are rendered honestly, then a full replacement is
+ // requested immediately rather than mixing old markers with new state.
+ let lastRequest = lastMapGeoRecoveryRequestAt
+ if lastRequest == nil ||
+ arrival.timeIntervalSince(lastRequest ?? .distantPast) >=
+ Self.mapGeoRecoveryThrottle
+ {
+ lastMapGeoRecoveryRequestAt = arrival
+ sendMapGeoPreference(true, force: true)
+ }
+ }
+
+ if let cue = decoded.cue,
+ Self.isFresh(cue, at: arrival),
+ presentedCueIDs.insert(cue.id).inserted
+ {
+ presentedCueIDOrder.append(cue.id)
+ // The phone bounds its command-ID cache for the same reason: a watch
+ // process can live for days, while only recent redelivery matters.
+ if presentedCueIDOrder.count > 64 {
+ presentedCueIDs.remove(presentedCueIDOrder.removeFirst())
+ }
+ // Played here rather than from a view: this branch is already the one
+ // place that decides a cue is new, fresh, and worth presenting. A view
+ // would have to re-derive that gate from exported state and would get
+ // it wrong on redelivery, which WatchConnectivity does routinely.
+ play(cue)
+ if let message = cue.message, !message.isEmpty {
+ // A cue may be a late wrist-command failure or an unrelated phone
+ // event; the current wire cannot distinguish them, so attribution
+ // here would be a guess. Correlating it later requires carrying the
+ // originating command on `WatchHapticCue` across the wire.
+ setLastRefusal(message, from: nil)
+ }
+ }
+ }
+}
+
+// MARK: - WCSessionDelegate
+
+/// Every method here is `nonisolated`, because WatchConnectivity documents that
+/// "the methods of this protocol are called on a background thread of your app"
+/// and asks that any resulting interface change be redirected to the main
+/// thread. `ingest` was already doing that for the payload; the activation path
+/// was not, and reached `pendingRefresh`, `lastSentMapGeoNeeded` and
+/// `mapGeoSuppressionTask` — all `@Observable`-tracked and all read on the main
+/// actor — straight from the delivery queue.
+///
+/// Decoding stays off the main actor deliberately. It is the only expensive
+/// thing that happens here, it touches no state, and pushing a 12 kB JSON parse
+/// onto the wrist's main thread on every snapshot would trade one defect for a
+/// worse one.
+extension WatchSessionClient: WCSessionDelegate {
+ nonisolated func session(
+ _ session: WCSession,
+ activationDidCompleteWith activationState: WCSessionActivationState,
+ error: Error?
+ ) {
+ guard activationState == .activated else { return }
+ ingest(context: session.receivedApplicationContext)
+ let reachable = session.isReachable
+ Task { @MainActor [weak self] in
+ self?.noteReachability(reachable)
+ self?.completeActivation()
+ }
+ }
+
+ nonisolated func session(
+ _ session: WCSession,
+ didReceiveApplicationContext applicationContext: [String: Any]
+ ) {
+ ingest(context: applicationContext)
+ }
+
+ nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
+ guard let data = message[MeshMapperWatchWire.payloadKey] as? Data else { return }
+ ingest(data: data)
+ }
+
+ /// The documented signal for `isReachable` changing. Without it the property
+ /// is only ever as current as the last activation.
+ nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
+ let reachable = session.isReachable
+ Task { @MainActor [weak self] in self?.noteReachability(reachable) }
+ }
+}
diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift
new file mode 100644
index 0000000..92c3c35
--- /dev/null
+++ b/ios/MeshMapperWatch/WatchSettings.swift
@@ -0,0 +1,380 @@
+import CoreLocation
+import Foundation
+import SwiftUI
+
+/// Wrist-side preferences.
+///
+/// These are watch-local on purpose: they describe how this small screen is
+/// laid out, not anything about the mapping session, so syncing them from the
+/// phone would add a round-trip for no benefit.
+@Observable
+final class WatchSettings {
+ private enum Key {
+ static let showLinks = "map.showLinks"
+ static let follow = "map.follow"
+ static let mapLatitudeDelta = "map.latitudeDelta"
+ static let mapZoomDefaultsVersion = "map.zoomDefaultsVersion"
+ static let mapLastCenter = "map.lastCenter"
+ static let mainPageContent = "layout.mainPageContent"
+ static let nodeListPlacement = "layout.nodeListPlacement"
+ static let defaultStartMode = "controls.defaultStartMode"
+ static let showPingWhenAvailable = "controls.showPingWhenAvailable"
+ static let haptics = "controls.haptics"
+ }
+
+ /// Roughly 250 m north-south: one degree of latitude is about 111,320 m.
+ static let defaultMapLatitudeDelta = 0.00225
+ /// The Crown's own range is wider than this at both ends, and the two bounds
+ /// are set for different reasons.
+ ///
+ /// **The floor tracks the hardware.** Measured on a Series 9, MapKit's
+ /// tightest Crown zoom is about 0.000230 degrees (~26 m); the floor used to
+ /// be 0.0005 (~56 m), so zooming in past that was clamped and the next
+ /// re-assert snapped the map back out. The wearer reported it as the map not
+ /// holding position. 0.0002 sits just below the observed limit so the Crown
+ /// is never fought on the way in.
+ ///
+ /// **The ceiling is a policy, not a measurement.** The Crown reaches about
+ /// 25 degrees (~2,800 km), and persisting that would let the app open showing
+ /// a continent — visually indistinguishable from the `.automatic` bug this
+ /// file exists to prevent, just chosen rather than inflicted. 0.5 (~56 km)
+ /// keeps a stray flick from becoming a sticky state. Zooming out past it
+ /// still works; only the persisted value is capped.
+ private static let mapLatitudeDeltaLimits = 0.0002...0.5
+ private static let mapZoomDefaultsVersion = 1
+
+ /// Where the recently-responded list lives.
+ ///
+ /// Both layouts are built from one view model, so this is a presentation
+ /// toggle rather than two code paths. Its own page is the default — chosen
+ /// for the room it gives the rows — and the sheet stays for wearers who would
+ /// rather keep the map behind the list.
+ enum NodeListPlacement: String, CaseIterable, Identifiable {
+ case sheet
+ case page
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .sheet: return "Sheet over map"
+ case .page: return "Its own page"
+ }
+ }
+ }
+
+ /// What occupies the app's first page at full luminance.
+ ///
+ /// Reduced luminance remains a separate system condition: either choice can
+ /// still enter the power-frugal readout when the wrist drops.
+ enum MainPageContent: String, CaseIterable, Identifiable {
+ case map
+ case readout
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .map: return "Map"
+ case .readout: return "Readout"
+ }
+ }
+ }
+
+ /// The explicit mode a wrist Start will request. The phone remains the final
+ /// authority and can refuse Hybrid if zone policy changed after the snapshot.
+ enum DefaultStartMode: String, CaseIterable, Identifiable {
+ case passive
+ case hybrid
+
+ var id: String { rawValue }
+
+ var label: String {
+ switch self {
+ case .passive: return "Passive"
+ case .hybrid: return "Hybrid"
+ }
+ }
+ }
+
+ private let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ showLinks = defaults.bool(forKey: Key.showLinks)
+ // Following the fix is the useful default while driving; absent any
+ // stored value `bool(forKey:)` returns false, so invert an explicit flag.
+ follow = defaults.object(forKey: Key.follow) as? Bool ?? true
+ // Rendered MapKit spans drift from the region requested, so an equality
+ // test cannot identify the old default reliably. A versioned migration
+ // deliberately resets every existing install once; after recording the
+ // version, ordinary Crown write-back owns the value again.
+ if defaults.integer(forKey: Key.mapZoomDefaultsVersion) < Self.mapZoomDefaultsVersion {
+ mapLatitudeDelta = Self.defaultMapLatitudeDelta
+ defaults.set(Self.defaultMapLatitudeDelta, forKey: Key.mapLatitudeDelta)
+ defaults.set(Self.mapZoomDefaultsVersion, forKey: Key.mapZoomDefaultsVersion)
+ } else {
+ // `double(forKey:)` turns absence into zero, which would silently select
+ // the minimum zoom. Preserve the distinction so an absent value gets the
+ // deliberate ~250 m default instead.
+ mapLatitudeDelta = Self.clampedMapLatitudeDelta(
+ defaults.object(forKey: Key.mapLatitudeDelta) as? Double
+ ?? Self.defaultMapLatitudeDelta
+ )
+ }
+ mainPageContent = (defaults.string(forKey: Key.mainPageContent))
+ .flatMap(MainPageContent.init(rawValue:)) ?? .map
+ nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement))
+ .flatMap(NodeListPlacement.init(rawValue:)) ?? .page
+ defaultStartMode = (defaults.string(forKey: Key.defaultStartMode))
+ .flatMap(DefaultStartMode.init(rawValue:)) ?? .passive
+ showPingWhenAvailable = defaults.object(
+ forKey: Key.showPingWhenAvailable
+ ) as? Bool ?? false
+ // Absent means on, so `bool(forKey:)` cannot be used: it turns a fresh
+ // install into a silent one. Same inversion as `follow` above.
+ haptics = defaults.object(forKey: Key.haptics) as? Bool ?? true
+ }
+
+ /// Draw a line from the fix to each repeater that answered the last ping.
+ var showLinks: Bool {
+ didSet { defaults.set(showLinks, forKey: Key.showLinks) }
+ }
+
+ /// Keep the camera on the phone's position.
+ var follow: Bool {
+ didSet { defaults.set(follow, forKey: Key.follow) }
+ }
+
+ /// Only latitude is persisted. MapKit fits longitude to the watch's aspect
+ /// ratio, so storing both would preserve two values that are not independent.
+ /// Normalising before persistence keeps a bad Crown result from becoming a
+ /// sticky launch state.
+ var mapLatitudeDelta: Double {
+ didSet {
+ let clamped = Self.clampedMapLatitudeDelta(mapLatitudeDelta)
+ if clamped != mapLatitudeDelta {
+ mapLatitudeDelta = clamped
+ }
+ // Persist the normalised value explicitly rather than relying on an
+ // assignment inside `didSet` to invoke the observer a second time.
+ defaults.set(clamped, forKey: Key.mapLatitudeDelta)
+ }
+ }
+
+ /// Where the camera last rested, so a map with no live fix opens on the last
+ /// place the wearer actually saw rather than on MapKit's annotation fit.
+ ///
+ /// `.automatic` is not a neutral starting state: measured on a Series 9 it
+ /// renders 34.4 degrees of latitude, about 3,800 km, and once the camera
+ /// lands there nothing in `MapPage` moves it back. This value is the seed
+ /// that keeps that from being reachable — see `anchorCameraIfNeeded`.
+ ///
+ /// Deliberately *not* `@Observable`-backed state: nothing renders from it, it
+ /// is read once per native-map lifetime, and making it observable would
+ /// invalidate the map on every GPS step it records.
+ ///
+ /// Absence is distinguished from zero the same way the span is. `0, 0` is a
+ /// real coordinate in the Gulf of Guinea, so treating a missing key as zero
+ /// would open a fresh install in the Atlantic rather than falling through to
+ /// the live fix.
+ ///
+ /// Stored as one array rather than a latitude key, a longitude key and a
+ /// timestamp key. Separate writes can be torn apart by the app being killed
+ /// between them — watchOS terminates this app freely, which is the whole
+ /// reason the value is persisted — and half a coordinate is a
+ /// plausible-looking centre somewhere on the equator or the prime meridian.
+ /// One key cannot tear.
+ var lastMapCenter: CLLocationCoordinate2D? {
+ storedMapCenter?.center
+ }
+
+ /// The stored centre only while it still plausibly describes where the wearer
+ /// is, which is what lets a live fix outrank it.
+ ///
+ /// Without an age, a centre saved in another city wins over a real fix
+ /// forever once Follow is off. That matters more here than it would on the
+ /// phone, because `interactionModes` is `[.zoom]` — the wearer cannot pan out
+ /// of a wrong place, only zoom within it.
+ ///
+ /// Twelve hours is chosen to survive a full session and a break, while not
+ /// surviving a night's travel. Note that a fresh centre and a live fix agree
+ /// whenever the wearer has not moved, so this threshold only decides the case
+ /// where they *have* — which is exactly the case the live fix answers better.
+ var freshMapCenter: CLLocationCoordinate2D? {
+ guard let stored = storedMapCenter,
+ let savedAt = stored.savedAt,
+ Date().timeIntervalSince1970 - savedAt < Self.mapCenterFreshnessSeconds
+ else { return nil }
+ return stored.center
+ }
+
+ private static let mapCenterFreshnessSeconds: TimeInterval = 12 * 60 * 60
+
+ /// A missing timestamp is treated as unknown age rather than as fresh, so an
+ /// older two-element value can never masquerade as current.
+ private var storedMapCenter: (center: CLLocationCoordinate2D, savedAt: TimeInterval?)? {
+ guard
+ let stored = defaults.array(forKey: Key.mapLastCenter) as? [Double],
+ stored.count >= 2
+ else { return nil }
+ let (lat, lon) = (stored[0], stored[1])
+ guard
+ lat.isFinite, lon.isFinite,
+ (-90...90).contains(lat), (-180...180).contains(lon)
+ else { return nil }
+ let savedAt = stored.count >= 3 && stored[2].isFinite ? stored[2] : nil
+ return (CLLocationCoordinate2D(latitude: lat, longitude: lon), savedAt)
+ }
+
+ /// Record a centre the camera was actually driven to.
+ ///
+ /// Throttled because follow asserts a region on every geo update, and the
+ /// phone's own `minMoveMeters` filter (15 m) is *finer* than anything this
+ /// value needs — left unthrottled at walking pace this would write roughly
+ /// once every 11 seconds for the life of a session, against active battery
+ /// work.
+ ///
+ /// A coarse threshold is affordable because this is only ever read at app
+ /// launch: within a session `MapPage` holds the live centre in memory and
+ /// never consults this. So the stored value only has to be good enough to
+ /// open the map in the right place, and it is competing against `.automatic`
+ /// at 3,800 km — not against a perfect restore.
+ func noteMapCenter(_ center: CLLocationCoordinate2D) {
+ guard
+ center.latitude.isFinite, center.longitude.isFinite,
+ (-90...90).contains(center.latitude),
+ (-180...180).contains(center.longitude)
+ else { return }
+
+ // Freshness is part of the skip test, not just position. A wearer who stays
+ // put would otherwise match the stored coordinates forever, never rewrite
+ // the entry, and so let its timestamp expire under an accurate centre —
+ // the map would then decline to use a position that never stopped being
+ // correct. Requiring freshness here costs one write per expiry window.
+ if let stored = lastMapCenter, freshMapCenter != nil,
+ abs(stored.latitude - center.latitude) < Self.mapCenterWriteThreshold,
+ abs(stored.longitude - center.longitude) < Self.mapCenterWriteThreshold
+ {
+ return
+ }
+ defaults.set(
+ [center.latitude, center.longitude, Date().timeIntervalSince1970],
+ forKey: Key.mapLastCenter
+ )
+ }
+
+ /// About 110 m of latitude. Degrees, not metres: longitude convergence is
+ /// irrelevant to a write throttle, and treating it as a distance would imply
+ /// a precision this value does not need.
+ private static let mapCenterWriteThreshold = 0.001
+
+ var mainPageContent: MainPageContent {
+ didSet { defaults.set(mainPageContent.rawValue, forKey: Key.mainPageContent) }
+ }
+
+ var nodeListPlacement: NodeListPlacement {
+ didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) }
+ }
+
+ var defaultStartMode: DefaultStartMode {
+ didSet { defaults.set(defaultStartMode.rawValue, forKey: Key.defaultStartMode) }
+ }
+
+ var showPingWhenAvailable: Bool {
+ didSet { defaults.set(showPingWhenAvailable, forKey: Key.showPingWhenAvailable) }
+ }
+
+ /// Whether a phone-issued cue is allowed to reach the Taptic Engine.
+ ///
+ /// Wrist-local like everything else here, and for a stronger reason than
+ /// layout: the wearer is the person being buzzed, and a phone round-trip
+ /// would make silencing it depend on the very link that just failed.
+ ///
+ /// On by default because the only cue the phone currently sends is a
+ /// `failure` for a wrist command it had already accepted — rare, never part
+ /// of a routine ping cycle, and always the direct consequence of something
+ /// the wearer just tapped.
+ var haptics: Bool {
+ didSet { defaults.set(haptics, forKey: Key.haptics) }
+ }
+
+ #if DEBUG
+ /// Freeze the countdown bar's drain, for measuring what that animation costs.
+ ///
+ /// Read statically rather than through an instance so the animation path does
+ /// not take an observation dependency on it: flipping this must change the
+ /// next phase, not invalidate the view mid-drain and perturb the very trace
+ /// it exists to produce.
+ static var debugFreezesTimerBar: Bool {
+ UserDefaults.standard.bool(forKey: "MeshMapperFreezeTimerBar")
+ }
+
+ var freezesTimerBar: Bool {
+ get { Self.debugFreezesTimerBar }
+ set { defaults.set(newValue, forKey: "MeshMapperFreezeTimerBar") }
+ }
+
+ /// Record wrist-raise timings to `WakeLog`.
+ ///
+ /// **Persisted deliberately, and this is the whole point.** The same job was
+ /// first done by a `-MeshMapperTimeSwitchToMap` launch argument, which cannot
+ /// work here: launch arguments live in `NSArgumentDomain` and evaporate the
+ /// moment watchOS terminates and relaunches the app — which is exactly what a
+ /// wrist-down test provokes. The gate closed silently and the run produced an
+ /// empty log with nothing to indicate why. A measurement switch that outlives
+ /// process death has to be persisted, and being on the wrist means it can be
+ /// flipped without a reinstall.
+ static var debugLogsWakeTiming: Bool {
+ UserDefaults.standard.bool(forKey: "MeshMapperLogWakeTiming")
+ || UserDefaults.standard.bool(forKey: "MeshMapperTimeSwitchToMap")
+ }
+
+ var logsWakeTiming: Bool {
+ get { Self.debugLogsWakeTiming }
+ set { defaults.set(newValue, forKey: "MeshMapperLogWakeTiming") }
+ }
+ #endif
+
+ /// One promise shared by Settings and the explicit-mode Start control. If
+ /// these surfaces resolve independently, Settings can claim Hybrid while a
+ /// tap silently requests Passive — precisely the kind of mode ambiguity the
+ /// explicit command field exists to prevent.
+ func effectiveStartMode(
+ availableStartModes: [String]?
+ ) -> DefaultStartMode {
+ let advertised = availableStartModes ?? [DefaultStartMode.passive.rawValue]
+ return advertised.contains(defaultStartMode.rawValue)
+ ? defaultStartMode
+ : .passive
+ }
+
+ private static func clampedMapLatitudeDelta(_ value: Double) -> Double {
+ guard value.isFinite else { return defaultMapLatitudeDelta }
+ return min(max(value, mapLatitudeDeltaLimits.lowerBound), mapLatitudeDeltaLimits.upperBound)
+ }
+}
+
+/// Shared control chrome, lifted from the phone rather than accumulating
+/// unrelated system tints across wrist surfaces. Ping and repeater data colours
+/// are different: those arrive resolved through the phone's colour-vision
+/// palette, while these action semantics remain fixed just as today's red and
+/// green buttons do. Revisit this boundary if watch controls become palette-aware.
+enum WatchPalette {
+ static let start = Color(red: 34 / 255, green: 197 / 255, blue: 94 / 255)
+ static let stop = Color(red: 189 / 255, green: 33 / 255, blue: 48 / 255)
+ static let ping = Color(red: 99 / 255, green: 102 / 255, blue: 241 / 255)
+ static let armed = Color(red: 245 / 255, green: 158 / 255, blue: 11 / 255)
+ static let disabled = Color(red: 51 / 255, green: 65 / 255, blue: 85 / 255)
+ static let secondary = Color(red: 71 / 255, green: 85 / 255, blue: 105 / 255)
+ static let tertiary = Color(red: 148 / 255, green: 163 / 255, blue: 184 / 255)
+ static let cornerRadius: CGFloat = 12
+}
+
+extension Color {
+ /// Colours arrive already resolved from the phone's colour-vision palette,
+ /// so the watch never needs to know which palette is active.
+ init(_ watchColor: WatchColor) {
+ self.init(red: watchColor.r, green: watchColor.g, blue: watchColor.b)
+ }
+}
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index d045a55..f34448d 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -7,16 +7,37 @@
objects = {
/* Begin PBXBuildFile section */
+ 05408ED93BBD28C6335AD1A3 /* DebugPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775B3F6C0618B4BD5644B40B /* NodeListView.swift */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84205AF3825B4E2EF987B27E /* SettingsPage.swift */; };
+ 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */; };
+ 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8523E9AE78A9549CE601F697 /* WatchSettings.swift */; };
+ 57C05889BAC05567849ED416 /* MapPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EA64785988022009D0587B1 /* MapPage.swift */; };
+ 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */; };
+ 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10A44B241BC20153F36C053D /* SampleSnapshot.swift */; };
73F9D344DD4B7EC7AC29CD86 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
75D889865C3C829654189002 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
+ 91E86E700B648483F172FAA5 /* ControlsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
- 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
+ A10000000000000000000001 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LiveActivityManager.swift */; };
+ A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* MeshMapperActivityAttributes.swift */; };
+ A10000000000000000000003 /* MeshMapperActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* MeshMapperActivityAttributes.swift */; };
+ A10000000000000000000004 /* MeshMapperLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* MeshMapperLiveActivity.swift */; };
+ A10000000000000000000005 /* MeshMapperLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */; };
+ A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+ A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 178C77B846A00943CD881203 /* WatchSessionClient.swift */; };
+ B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */; };
+ E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */; };
+ E83718073D76FB741949ED05 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6CC8647C002484845F02D0CE /* Assets.xcassets */; };
+ F857D97D425B45AB76FD4B2F /* MeshMapperWatchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -27,9 +48,34 @@
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
+ 964501C97A62900A93BB2E74 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 26F4F6F7B0F55BC605124558;
+ remoteInfo = MeshMapperWatch;
+ };
+ A60000000000000000000001 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = A50000000000000000000001;
+ remoteInfo = MeshMapperLiveActivityExtension;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
+ 6D0B327AA07726C8BA4AA892 /* Embed Watch Content */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
+ dstSubfolderSpec = 16;
+ files = (
+ 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */,
+ );
+ name = "Embed Watch Content";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -40,19 +86,45 @@
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
+ A40000000000000000000004 /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ A10000000000000000000006 /* MeshMapperLiveActivityExtension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ 10A44B241BC20153F36C053D /* SampleSnapshot.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SampleSnapshot.swift; sourceTree = ""; };
+ 111B35B32FAB66FA2E78E0BE /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 178C77B846A00943CD881203 /* WatchSessionClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSessionClient.swift; sourceTree = ""; };
+ 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ControlsPage.swift; sourceTree = ""; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 3EA64785988022009D0587B1 /* MapPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapPage.swift; sourceTree = ""; };
+ 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; };
6316620B3FF7A48DF8F886CA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; };
+ 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeshMapperWatchPayload.swift; sourceTree = ""; };
+ 6CC8647C002484845F02D0CE /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MeshMapperWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 775B3F6C0618B4BD5644B40B /* NodeListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NodeListView.swift; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 84205AF3825B4E2EF987B27E /* SettingsPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsPage.swift; sourceTree = ""; };
+ 8523E9AE78A9549CE601F697 /* WatchSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSettings.swift; sourceTree = ""; };
86A4D8E4F5F13D005DA717B4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
+ 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MeshMapperWatchApp.swift; sourceTree = ""; };
8BA04B67488852DCDA49C863 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
@@ -62,14 +134,28 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DebugPage.swift; sourceTree = ""; };
+ A20000000000000000000001 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = ""; };
+ A20000000000000000000002 /* MeshMapperActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperActivityAttributes.swift; sourceTree = ""; };
+ A20000000000000000000003 /* MeshMapperLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperLiveActivity.swift; sourceTree = ""; };
+ A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMapperLiveActivityBundle.swift; sourceTree = ""; };
+ A20000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MeshMapperLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ A20000000000000000000007 /* LiveActivity.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = LiveActivity.xcconfig; path = Flutter/LiveActivity.xcconfig; sourceTree = ""; };
B1A260696FEFA53606484258 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; };
C5DCC2A7546C7F71461B567A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
D478469A7D705340684EFF2B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
- 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
+ 27F55F79605B24B31D20B683 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
507BD8685B0F59BD768C20E3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -87,6 +173,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ A40000000000000000000002 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -95,10 +188,38 @@
children = (
8CAA1E3000FEC19EE9ED5CFE /* Pods_Runner.framework */,
F799CC3DB45F3F5C30B5907D /* Pods_RunnerTests.framework */,
+ 321ECD9614BDCAFCF545D162 /* watchOS */,
);
name = Frameworks;
sourceTree = "";
};
+ 2CEC7BCDA4A562621033D8AD /* MeshMapperWatch */ = {
+ isa = PBXGroup;
+ children = (
+ 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */,
+ 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */,
+ 111B35B32FAB66FA2E78E0BE /* Info.plist */,
+ 6CC8647C002484845F02D0CE /* Assets.xcassets */,
+ 178C77B846A00943CD881203 /* WatchSessionClient.swift */,
+ 3EA64785988022009D0587B1 /* MapPage.swift */,
+ 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */,
+ 84205AF3825B4E2EF987B27E /* SettingsPage.swift */,
+ 8523E9AE78A9549CE601F697 /* WatchSettings.swift */,
+ 10A44B241BC20153F36C053D /* SampleSnapshot.swift */,
+ 775B3F6C0618B4BD5644B40B /* NodeListView.swift */,
+ 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */,
+ );
+ name = MeshMapperWatch;
+ path = MeshMapperWatch;
+ sourceTree = "";
+ };
+ 321ECD9614BDCAFCF545D162 /* watchOS */ = {
+ isa = PBXGroup;
+ children = (
+ );
+ name = watchOS;
+ sourceTree = "";
+ };
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
@@ -128,6 +249,7 @@
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ A20000000000000000000007 /* LiveActivity.xcconfig */,
);
name = Flutter;
sourceTree = "";
@@ -137,10 +259,13 @@
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
+ A30000000000000000000001 /* Shared */,
+ A30000000000000000000002 /* MeshMapperLiveActivity */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
88C2A374596BDB4F1DE4A0B4 /* Pods */,
1E85EC0983B84C8E2376ABCD /* Frameworks */,
+ 2CEC7BCDA4A562621033D8AD /* MeshMapperWatch */,
);
sourceTree = "";
};
@@ -149,6 +274,8 @@
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */,
+ 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */,
);
name = Products;
sourceTree = "";
@@ -163,14 +290,52 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ A20000000000000000000001 /* LiveActivityManager.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */,
);
path = Runner;
sourceTree = "";
};
+ A30000000000000000000001 /* Shared */ = {
+ isa = PBXGroup;
+ children = (
+ A20000000000000000000002 /* MeshMapperActivityAttributes.swift */,
+ 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */,
+ );
+ path = Shared;
+ sourceTree = "";
+ };
+ A30000000000000000000002 /* MeshMapperLiveActivity */ = {
+ isa = PBXGroup;
+ children = (
+ A20000000000000000000003 /* MeshMapperLiveActivity.swift */,
+ A20000000000000000000004 /* MeshMapperLiveActivityBundle.swift */,
+ A20000000000000000000005 /* Info.plist */,
+ );
+ path = MeshMapperLiveActivity;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
+ 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = E32607468DAE38F2F71EADD5 /* Build configuration list for PBXNativeTarget "MeshMapperWatch" */;
+ buildPhases = (
+ FBC0EF55C50DCA90E416F7D1 /* Sources */,
+ 27F55F79605B24B31D20B683 /* Frameworks */,
+ 4039BA5EEB0F88665D8EAFE2 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = MeshMapperWatch;
+ productName = MeshMapperWatch;
+ productReference = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */;
+ productType = "com.apple.product-type.application";
+ };
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
@@ -191,13 +356,12 @@
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
- packageProductDependencies = (
- 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
- );
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
E42ACB57393E36F474C65ADB /* [CP] Check Pods Manifest.lock */,
+ A40000000000000000000004 /* Embed Foundation Extensions */,
+ 6D0B327AA07726C8BA4AA892 /* Embed Watch Content */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
@@ -210,19 +374,38 @@
buildRules = (
);
dependencies = (
+ A70000000000000000000001 /* PBXTargetDependency */,
+ F203972D6336527AC5F4F89D /* PBXTargetDependency */,
);
name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
+ A50000000000000000000001 /* MeshMapperLiveActivityExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = A90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperLiveActivityExtension" */;
+ buildPhases = (
+ A40000000000000000000001 /* Sources */,
+ A40000000000000000000002 /* Frameworks */,
+ A40000000000000000000003 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = MeshMapperLiveActivityExtension;
+ productName = MeshMapperLiveActivityExtension;
+ productReference = A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
- packageReferences = (
- 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
- );
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
@@ -237,6 +420,9 @@
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
+ A50000000000000000000001 = {
+ CreatedOnToolsVersion = 15.1;
+ };
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
@@ -248,12 +434,17 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
+ );
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
+ A50000000000000000000001 /* MeshMapperLiveActivityExtension */,
+ 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */,
);
};
/* End PBXProject section */
@@ -266,6 +457,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 4039BA5EEB0F88665D8EAFE2 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E83718073D76FB741949ED05 /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -277,6 +476,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ A40000000000000000000003 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -405,7 +611,39 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ A10000000000000000000001 /* LiveActivityManager.swift in Sources */,
+ A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ F857D97D425B45AB76FD4B2F /* MeshMapperWatchPayload.swift in Sources */,
+ 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ A40000000000000000000001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ A10000000000000000000003 /* MeshMapperActivityAttributes.swift in Sources */,
+ A10000000000000000000004 /* MeshMapperLiveActivity.swift in Sources */,
+ A10000000000000000000005 /* MeshMapperLiveActivityBundle.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ FBC0EF55C50DCA90E416F7D1 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */,
+ 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */,
+ B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */,
+ A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */,
+ 57C05889BAC05567849ED416 /* MapPage.swift in Sources */,
+ 05408ED93BBD28C6335AD1A3 /* DebugPage.swift in Sources */,
+ 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */,
+ 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */,
+ 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */,
+ 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */,
+ 91E86E700B648483F172FAA5 /* ControlsPage.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -417,6 +655,17 @@
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
+ A70000000000000000000001 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = A50000000000000000000001 /* MeshMapperLiveActivityExtension */;
+ targetProxy = A60000000000000000000001 /* PBXContainerItemProxy */;
+ };
+ F203972D6336527AC5F4F89D /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ name = MeshMapperWatch;
+ target = 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */;
+ targetProxy = 964501C97A62900A93BB2E74 /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
@@ -472,7 +721,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
- DEVELOPMENT_TEAM = DQC6TNKG5P;
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -485,6 +734,8 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app;
+ MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -507,7 +758,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -525,7 +776,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -544,7 +795,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -561,13 +812,42 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
+ 705BC20CAD6B880CACD9EA74 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeshMapperWatch/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = watchos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ VALIDATE_PRODUCT = YES;
+ WATCHOS_DEPLOYMENT_TARGET = 11.0;
+ };
+ name = Profile;
+ };
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -601,7 +881,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
- DEVELOPMENT_TEAM = DQC6TNKG5P;
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -620,6 +900,8 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app;
+ MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -661,7 +943,7 @@
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
- DEVELOPMENT_TEAM = DQC6TNKG5P;
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -674,6 +956,8 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MESHMAPPER_BUNDLE_PREFIX = net.meshmapper.app;
+ MESHMAPPER_DEVELOPMENT_TEAM = DQC6TNKG5P;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -698,7 +982,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -720,7 +1004,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app;
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -728,6 +1012,150 @@
};
name = Release;
};
+ A64C5101607B9C9371C99AB5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeshMapperWatch/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = watchos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ VALIDATE_PRODUCT = YES;
+ WATCHOS_DEPLOYMENT_TARGET = 11.0;
+ };
+ name = Release;
+ };
+ A80000000000000000000001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ A80000000000000000000002 /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ A80000000000000000000003 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */;
+ buildSettings = {
+ APPLICATION_EXTENSION_API_ONLY = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.2;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ "@executable_path/../../Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SKIP_INSTALL = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Profile;
+ };
+ FBECC62B604ABFF4A75248F1 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)";
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = MeshMapperWatch/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
+ PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).watchkitapp";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = watchos;
+ SKIP_INSTALL = YES;
+ SUPPORTED_PLATFORMS = "watchos watchsimulator";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = 4;
+ WATCHOS_DEPLOYMENT_TARGET = 11.0;
+ };
+ name = Debug;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -761,13 +1189,35 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ A90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperLiveActivityExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A80000000000000000000001 /* Debug */,
+ A80000000000000000000002 /* Release */,
+ A80000000000000000000003 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ E32607468DAE38F2F71EADD5 /* Build configuration list for PBXNativeTarget "MeshMapperWatch" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ A64C5101607B9C9371C99AB5 /* Release */,
+ FBECC62B604ABFF4A75248F1 /* Debug */,
+ 705BC20CAD6B880CACD9EA74 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
+
/* Begin XCLocalSwiftPackageReference section */
- 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
+
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 34f4799..3f7e7a1 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -67,6 +67,8 @@ class IOSMapOfflineBridge {
@main
@objc class AppDelegate: FlutterAppDelegate {
private let mapOfflineBridge = IOSMapOfflineBridge()
+ private let liveActivityManager = LiveActivityManager()
+ private let watchSessionManager = WatchSessionManager()
override func application(
_ application: UIApplication,
@@ -108,6 +110,37 @@ class IOSMapOfflineBridge {
}
}
+ // Method channel: local ActivityKit status for active wardriving
+ // sessions. The widget extension renders the Lock Screen, Dynamic Island,
+ // and compact system/CarPlay presentations from these snapshots.
+ let liveActivityChannel = FlutterMethodChannel(
+ name: "meshmapper/live_activity",
+ binaryMessenger: controller.binaryMessenger
+ )
+ liveActivityChannel.setMethodCallHandler { [weak self] call, result in
+ guard let self = self else {
+ result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil))
+ return
+ }
+ self.liveActivityManager.handle(call, result: result)
+ }
+
+ // Method channel: watchOS companion. Dart pushes WatchSnapshots down
+ // and the watch sends start/stop/manual-ping intents back up the same
+ // channel. Unlike the Live Activity, this needs no entitlement.
+ let watchChannel = FlutterMethodChannel(
+ name: "meshmapper/watch",
+ binaryMessenger: controller.binaryMessenger
+ )
+ watchChannel.setMethodCallHandler { [weak self] call, result in
+ guard let self = self else {
+ result(FlutterError(code: "unavailable", message: "bridge deallocated", details: nil))
+ return
+ }
+ self.watchSessionManager.handle(call, result: result)
+ }
+ watchSessionManager.attach(channel: watchChannel)
+
// Method channel: MapLibre tile cache management. Mirrors the Android
// handler in MainActivity.kt. Dart's TileCacheService calls into these
// from the Offline Maps screen's Tile Cache card.
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 251402f..8722daf 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -156,5 +156,7 @@
UIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight
+ NSSupportsLiveActivities
+
diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift
new file mode 100644
index 0000000..1643387
--- /dev/null
+++ b/ios/Runner/LiveActivityManager.swift
@@ -0,0 +1,288 @@
+import ActivityKit
+import Flutter
+import Foundation
+
+/// Native ActivityKit endpoint for the Flutter method channel.
+///
+/// Flutter sends complete snapshots. The manager keeps at most one activity,
+/// updates it serially, and doesn't recreate a Live Activity the user dismissed
+/// during the same wardriving session.
+final class LiveActivityManager {
+ private enum BridgeError: LocalizedError {
+ case invalidArguments
+
+ var errorDescription: String? {
+ "The Live Activity payload was invalid."
+ }
+ }
+
+ private var requestedSessionID: String?
+
+ func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "sync":
+ guard let payload = call.arguments as? [String: Any] else {
+ result(flutterError(BridgeError.invalidArguments, code: "invalid_arguments"))
+ return
+ }
+ guard #available(iOS 16.2, *) else {
+ result(false)
+ return
+ }
+ guard ActivityAuthorizationInfo().areActivitiesEnabled else {
+ result(false)
+ return
+ }
+
+ Task { @MainActor in
+ do {
+ let parsed = try parse(payload)
+ try await sync(attributes: parsed.attributes, state: parsed.state)
+ result(true)
+ } catch {
+ result(self.flutterError(error, code: "sync_failed"))
+ }
+ }
+
+ case "end":
+ guard #available(iOS 16.2, *) else {
+ result(nil)
+ return
+ }
+ let immediate =
+ (call.arguments as? [String: Any])?["immediate"] as? Bool ?? false
+ Task { @MainActor in
+ await endAll(keepSummaryFor: immediate ? 0 : 60)
+ requestedSessionID = nil
+ result(nil)
+ }
+
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ private func flutterError(_ error: Error, code: String) -> FlutterError {
+ FlutterError(
+ code: code,
+ message: error.localizedDescription,
+ details: String(describing: error)
+ )
+ }
+
+ @available(iOS 16.2, *)
+ private func parse(
+ _ payload: [String: Any]
+ ) throws -> (
+ attributes: MeshMapperActivityAttributes,
+ state: MeshMapperActivityAttributes.ContentState
+ ) {
+ guard let sessionID = boundedString(payload["sessionId"], maxLength: 64),
+ let mode = boundedString(payload["mode"], maxLength: 16),
+ let phase = boundedString(payload["phase"], maxLength: 32),
+ let phaseTitle = boundedString(payload["phaseTitle"], maxLength: 48),
+ let updatedAt = date(payload["updatedAt"])
+ else {
+ throw BridgeError.invalidArguments
+ }
+
+ let repeaterPayloads = payload["repeaters"] as? [Any] ?? []
+ let repeaters = repeaterPayloads.prefix(3).compactMap {
+ rawItem -> MeshMapperActivityAttributes.HeardRepeater? in
+ guard let item = rawItem as? [String: Any],
+ let id = boundedString(item["id"], maxLength: 16),
+ let snr = finiteNumber(item["snr"])
+ else {
+ return nil
+ }
+ return MeshMapperActivityAttributes.HeardRepeater(
+ id: id,
+ name: boundedString(item["name"], maxLength: 36),
+ snr: min(max(snr, -200), 200),
+ typeColor: resolvedColor(item["typeColor"]),
+ snrColor: resolvedColor(item["snrColor"])
+ )
+ }
+
+ let attributes = MeshMapperActivityAttributes(sessionID: sessionID)
+ let state = MeshMapperActivityAttributes.ContentState(
+ mode: mode,
+ phase: phase,
+ phaseTitle: phaseTitle,
+ phaseDetail: boundedString(payload["phaseDetail"], maxLength: 80),
+ phaseEndsAt: date(payload["phaseEndsAt"]),
+ phaseDurationMs: positiveInteger(payload["phaseDurationMs"]),
+ pingColor: resolvedColor(payload["pingColor"]),
+ isConnected: payload["isConnected"] as? Bool ?? false,
+ zoneCode: boundedString(payload["zoneCode"], maxLength: 12),
+ txCount: nonnegativeInteger(payload["txCount"]),
+ rxCount: nonnegativeInteger(payload["rxCount"]),
+ discoveryCount: nonnegativeInteger(payload["discoveryCount"]),
+ traceCount: nonnegativeInteger(payload["traceCount"]),
+ queueSize: nonnegativeInteger(payload["queueSize"]),
+ repeaters: repeaters,
+ totalHeardCount: max(nonnegativeInteger(payload["totalHeardCount"]), repeaters.count),
+ repeatersAreCurrent: payload["repeatersAreCurrent"] as? Bool ?? false,
+ updatedAt: updatedAt
+ )
+ return (attributes, state)
+ }
+
+ @available(iOS 16.2, *)
+ @MainActor
+ private func sync(
+ attributes: MeshMapperActivityAttributes,
+ state: MeshMapperActivityAttributes.ContentState
+ ) async throws {
+ let activities = Activity.activities
+ let matching = activities.filter { $0.attributes.sessionID == attributes.sessionID }
+ let unrelated = activities.filter { $0.attributes.sessionID != attributes.sessionID }
+
+ for activity in unrelated {
+ await end(activity, keepSummaryFor: 0)
+ }
+
+ let content = ActivityContent(
+ state: state,
+ staleDate: staleDate(for: state)
+ )
+
+ if let activity = matching.first {
+ requestedSessionID = attributes.sessionID
+ await activity.update(content)
+ for duplicate in matching.dropFirst() {
+ await end(duplicate, keepSummaryFor: 0)
+ }
+ return
+ }
+
+ // A missing activity with the same session ID means the user or system
+ // dismissed it. Don't recreate it until MeshMapper starts a new session.
+ if requestedSessionID == attributes.sessionID {
+ return
+ }
+
+ requestedSessionID = attributes.sessionID
+ do {
+ _ = try Activity.request(
+ attributes: attributes,
+ content: content,
+ pushType: nil
+ )
+ } catch {
+ requestedSessionID = nil
+ throw error
+ }
+ }
+
+ @available(iOS 16.2, *)
+ private func staleDate(
+ for state: MeshMapperActivityAttributes.ContentState
+ ) -> Date {
+ if let phaseEndsAt = state.phaseEndsAt {
+ return phaseEndsAt.addingTimeInterval(45)
+ }
+ return state.updatedAt.addingTimeInterval(5 * 60)
+ }
+
+ @available(iOS 16.2, *)
+ @MainActor
+ private func endAll(keepSummaryFor seconds: TimeInterval) async {
+ for activity in Activity.activities {
+ await end(activity, keepSummaryFor: seconds)
+ }
+ }
+
+ @available(iOS 16.2, *)
+ @MainActor
+ private func end(
+ _ activity: Activity,
+ keepSummaryFor seconds: TimeInterval
+ ) async {
+ var finalState = activity.content.state
+ finalState.phase = "stopped"
+ finalState.phaseTitle = "Session ended"
+ finalState.phaseDetail = summary(for: finalState)
+ finalState.phaseEndsAt = nil
+ finalState.phaseDurationMs = nil
+ finalState.repeatersAreCurrent = false
+ finalState.updatedAt = Date()
+
+ let dismissalPolicy: ActivityUIDismissalPolicy =
+ seconds > 0
+ ? .after(Date().addingTimeInterval(seconds))
+ : .immediate
+ await activity.end(
+ ActivityContent(state: finalState, staleDate: nil),
+ dismissalPolicy: dismissalPolicy
+ )
+ }
+
+ @available(iOS 16.2, *)
+ private func summary(
+ for state: MeshMapperActivityAttributes.ContentState
+ ) -> String {
+ var parts = ["TX \(state.txCount)", "RX \(state.rxCount)"]
+ if state.discoveryCount > 0 { parts.append("DISC \(state.discoveryCount)") }
+ if state.traceCount > 0 { parts.append("TRACE \(state.traceCount)") }
+ return parts.joined(separator: " · ")
+ }
+
+ private func boundedString(_ value: Any?, maxLength: Int) -> String? {
+ guard let value = value as? String else { return nil }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ return String(trimmed.prefix(maxLength))
+ }
+
+ private func date(_ value: Any?) -> Date? {
+ guard let milliseconds = finiteNumber(value) else { return nil }
+ return Date(timeIntervalSince1970: milliseconds / 1000)
+ }
+
+ private func finiteNumber(_ value: Any?) -> Double? {
+ let parsed: Double?
+ if let number = value as? NSNumber {
+ parsed = number.doubleValue
+ } else if let value = value as? Double {
+ parsed = value
+ } else if let value = value as? Int {
+ parsed = Double(value)
+ } else {
+ parsed = nil
+ }
+ guard let parsed, parsed.isFinite else { return nil }
+ return parsed
+ }
+
+ private func nonnegativeInteger(_ value: Any?) -> Int {
+ if let number = value as? NSNumber { return max(number.intValue, 0) }
+ if let value = value as? Int { return max(value, 0) }
+ return 0
+ }
+
+ private func positiveInteger(_ value: Any?) -> Int? {
+ guard let value = finiteNumber(value), value > 0 else { return nil }
+ return Int(min(value, Double(24 * 60 * 60 * 1000)))
+ }
+
+ /// Guarded like every other member that names the attributes type: the
+ /// deployment target predates ActivityKit, so mentioning it unguarded fails
+ /// to compile even in a helper that never runs on an older OS.
+ @available(iOS 16.2, *)
+ private func resolvedColor(
+ _ value: Any?
+ ) -> MeshMapperActivityAttributes.ResolvedColor? {
+ guard let value = value as? [String: Any],
+ let r = finiteNumber(value["r"]),
+ let g = finiteNumber(value["g"]),
+ let b = finiteNumber(value["b"])
+ else { return nil }
+
+ return MeshMapperActivityAttributes.ResolvedColor(
+ r: min(max(r, 0), 1),
+ g: min(max(g, 0), 1),
+ b: min(max(b, 0), 1)
+ )
+ }
+}
diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift
new file mode 100644
index 0000000..7b4d82a
--- /dev/null
+++ b/ios/Runner/WatchSessionManager.swift
@@ -0,0 +1,272 @@
+import Flutter
+import Foundation
+import WatchConnectivity
+
+/// Bridges Flutter app state to the watchOS companion and relays commands back.
+///
+/// Two delivery paths, chosen by urgency:
+///
+/// - `updateApplicationContext` for the steady state. It coalesces (latest
+/// wins) and is delivered even when the watch app is backgrounded or not
+/// running, which is what makes the wrist-down case work.
+/// - `sendMessage` for phase transitions and ping results, which need to land
+/// now. It requires a reachable counterpart, so it always falls back to the
+/// application context rather than being the only path.
+///
+/// WatchConnectivity needs no entitlement and no capability registration —
+/// this whole file works without developer-portal access.
+final class WatchSessionManager: NSObject {
+ private enum BridgeError: LocalizedError {
+ case invalidArguments
+ case unsupported
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidArguments: return "The watch payload was invalid."
+ case .unsupported: return "WatchConnectivity is unavailable on this device."
+ }
+ }
+ }
+
+ /// Set by AppDelegate so inbound commands can reach Dart.
+ ///
+ /// Held strongly on purpose. `setMethodCallHandler` makes the binary
+ /// messenger retain the handler *block*, not the channel object, so a
+ /// channel left in a local goes away when `didFinishLaunchingWithOptions`
+ /// returns. The other channels in AppDelegate survive that because they
+ /// only ever receive calls; this one has to invoke Dart from native, so it
+ /// needs an owner. AppDelegate's handler block captures `self` weakly, so
+ /// this does not form a cycle.
+ private var channel: FlutterMethodChannel?
+
+ private var session: WCSession? {
+ WCSession.isSupported() ? WCSession.default : nil
+ }
+
+ /// Last context we successfully handed to WatchConnectivity, so a repeated
+ /// identical payload doesn't churn the radio.
+ private var lastContextData: Data?
+
+ func attach(channel: FlutterMethodChannel) {
+ self.channel = channel
+ activateIfNeeded()
+ }
+
+ private func activateIfNeeded() {
+ guard let session else { return }
+ if session.activationState != .activated {
+ session.delegate = self
+ session.activate()
+ }
+ }
+
+ // MARK: - Flutter → watch
+
+ func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "sync":
+ guard let args = call.arguments as? [String: Any],
+ let payload = args["payload"] as? [String: Any]
+ else {
+ result(flutterError(BridgeError.invalidArguments, code: "invalid_arguments"))
+ return
+ }
+ let urgent = args["urgent"] as? Bool ?? false
+ result(send(payload: payload, urgent: urgent))
+
+ case "clear":
+ lastContextData = nil
+ result(nil)
+
+ case "status":
+ result(statusDictionary())
+
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ /// Returns false when there is nowhere to deliver to — no paired watch, no
+ /// installed app, or no WatchConnectivity at all. Dart treats that as
+ /// "don't bother", not as an error.
+ private func send(payload: [String: Any], urgent: Bool) -> Bool {
+ guard let session, session.activationState == .activated else {
+ activateIfNeeded()
+ return false
+ }
+ guard session.isPaired, session.isWatchAppInstalled else { return false }
+
+ guard let data = try? JSONSerialization.data(withJSONObject: payload) else {
+ return false
+ }
+
+ // Urgent updates try the immediate path first, but always fall through to
+ // the application context so a missed message can't strand the watch on
+ // stale state.
+ if urgent, session.isReachable {
+ session.sendMessage(
+ [MeshMapperWatchWire.payloadKey: data],
+ replyHandler: nil,
+ errorHandler: { [weak self] error in
+ NSLog("[WATCH] sendMessage failed, context still pending: \(error.localizedDescription)")
+ _ = self
+ }
+ )
+ }
+
+ guard data != lastContextData else { return true }
+
+ do {
+ try session.updateApplicationContext([MeshMapperWatchWire.payloadKey: data])
+ lastContextData = data
+ return true
+ } catch {
+ NSLog("[WATCH] updateApplicationContext failed: \(error.localizedDescription)")
+ return false
+ }
+ }
+
+ private func statusDictionary() -> [String: Any] {
+ guard let session else {
+ return [
+ "supported": false,
+ "paired": false,
+ "installed": false,
+ "reachable": false,
+ "activated": false,
+ ]
+ }
+ return [
+ "supported": true,
+ "paired": session.isPaired,
+ "installed": session.isWatchAppInstalled,
+ "reachable": session.isReachable,
+ "activated": session.activationState == .activated,
+ ]
+ }
+
+ /// Dart performs the expensive geo build only while a real destination
+ /// exists. Push changes as well as answering its startup query: pairing and
+ /// app installation can happen after Flutter has been alive for hours, and
+ /// a launch-time false must never become a permanent gate.
+ private func publishStatus() {
+ guard let channel else { return }
+ DispatchQueue.main.async {
+ channel.invokeMethod("availabilityChanged", arguments: self.statusDictionary())
+ }
+ }
+
+ private func flutterError(_ error: Error, code: String) -> FlutterError {
+ FlutterError(
+ code: code,
+ message: error.localizedDescription,
+ details: String(describing: error)
+ )
+ }
+
+ // MARK: - watch → Flutter
+
+ /// Relays a command to Dart and returns its admission ack. Dart owns the
+ /// synchronous decision and starts accepted work separately; this side never
+ /// evaluates whether a transmit is legal or waits for BLE/network completion.
+ private func relayCommand(_ payload: [String: Any], reply: @escaping ([String: Any]) -> Void) {
+ guard let channel else {
+ NSLog("[WATCH] Command dropped: no method channel")
+ reply(["accepted": false, "reason": "App not ready"])
+ return
+ }
+
+ let kind = payload["kind"] as? String ?? "unknown"
+ NSLog("[WATCH] Command received: \(kind)")
+
+ // Method channel calls must happen on the main thread; WatchConnectivity
+ // delivers on a background queue.
+ DispatchQueue.main.async {
+ channel.invokeMethod("command", arguments: payload) { response in
+ if let dict = response as? [String: Any] {
+ NSLog("[WATCH] Command \(kind) acked: accepted=\(dict["accepted"] ?? "?")")
+ reply(dict)
+ } else {
+ NSLog("[WATCH] Command \(kind) got no response from Dart")
+ reply(["accepted": false, "reason": "No response"])
+ }
+ }
+ }
+ }
+}
+
+// MARK: - WCSessionDelegate
+
+extension WatchSessionManager: WCSessionDelegate {
+ func session(
+ _ session: WCSession,
+ activationDidCompleteWith activationState: WCSessionActivationState,
+ error: Error?
+ ) {
+ if let error {
+ NSLog("[WATCH] Activation failed: \(error.localizedDescription)")
+ }
+ publishStatus()
+ }
+
+ func sessionDidBecomeInactive(_ session: WCSession) {}
+
+ /// Reactivate after a watch switch, otherwise the session stays dead.
+ func sessionDidDeactivate(_ session: WCSession) {
+ session.activate()
+ }
+
+ func sessionWatchStateDidChange(_ session: WCSession) {
+ // Hopped, not written here. WatchConnectivity delivers this on a background
+ // thread while `send(payload:urgent:)` reads and writes the same property
+ // on the platform thread, which is a plain data race on the dedupe cache —
+ // and losing that race means a snapshot the watch needed gets suppressed as
+ // "identical" against a value another thread was midway through clearing.
+ DispatchQueue.main.async { [weak self] in
+ // A newly installed or newly paired watch has no context yet.
+ self?.lastContextData = nil
+ }
+ publishStatus()
+ }
+
+ /// The documented signal for reachability changing. Without it, the status
+ /// this bridge reports to Dart was only as current as the last activation or
+ /// watch-state change, and the diagnostics screen looked live purely because
+ /// it polls every five seconds while it is open.
+ func sessionReachabilityDidChange(_ session: WCSession) {
+ publishStatus()
+ }
+
+ func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
+ guard let command = userInfo[MeshMapperWatchWire.commandKey] as? [String: Any] else {
+ NSLog("[WATCH] Malformed queued command payload: \(Array(userInfo.keys))")
+ return
+ }
+ relayCommand(command) { _ in }
+ }
+
+ /// Retained for commands already sent by watch builds that predate the
+ /// queued transport; removing it would strand an in-flight wrist action.
+ func session(
+ _ session: WCSession,
+ didReceiveMessage message: [String: Any],
+ replyHandler: @escaping ([String: Any]) -> Void
+ ) {
+ guard let command = message[MeshMapperWatchWire.commandKey] as? [String: Any] else {
+ NSLog("[WATCH] Malformed command payload: \(Array(message.keys))")
+ replyHandler(["accepted": false, "reason": "Malformed command"])
+ return
+ }
+ relayCommand(command, reply: replyHandler)
+ }
+
+ /// The no-reply legacy overload is retained for the same compatibility
+ /// window. New watches use `transferUserInfo` exclusively.
+ func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
+ guard let command = message[MeshMapperWatchWire.commandKey] as? [String: Any] else {
+ NSLog("[WATCH] Malformed command payload: \(Array(message.keys))")
+ return
+ }
+ relayCommand(command) { _ in }
+ }
+}
diff --git a/ios/Shared/MeshMapperActivityAttributes.swift b/ios/Shared/MeshMapperActivityAttributes.swift
new file mode 100644
index 0000000..c42c029
--- /dev/null
+++ b/ios/Shared/MeshMapperActivityAttributes.swift
@@ -0,0 +1,46 @@
+import ActivityKit
+import Foundation
+
+/// Shared ActivityKit contract used by Runner and the widget extension.
+/// Keep the payload compact because ActivityKit limits attributes and state.
+@available(iOS 16.2, *)
+struct MeshMapperActivityAttributes: ActivityAttributes {
+ /// Same compact sRGB encoding as the watch payload. A separate Swift name
+ /// avoids coupling the widget target to the watch-only contract source.
+ struct ResolvedColor: Codable, Hashable {
+ let r: Double
+ let g: Double
+ let b: Double
+ }
+
+ struct HeardRepeater: Codable, Hashable, Identifiable {
+ let id: String
+ let name: String?
+ let snr: Double
+ let typeColor: ResolvedColor?
+ let snrColor: ResolvedColor?
+ }
+
+ struct ContentState: Codable, Hashable {
+ var mode: String
+ var phase: String
+ var phaseTitle: String
+ var phaseDetail: String?
+ var phaseEndsAt: Date?
+ var phaseDurationMs: Int?
+ var pingColor: ResolvedColor?
+ var isConnected: Bool
+ var zoneCode: String?
+ var txCount: Int
+ var rxCount: Int
+ var discoveryCount: Int
+ var traceCount: Int
+ var queueSize: Int
+ var repeaters: [HeardRepeater]
+ var totalHeardCount: Int
+ var repeatersAreCurrent: Bool
+ var updatedAt: Date
+ }
+
+ let sessionID: String
+}
diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift
new file mode 100644
index 0000000..98a3bde
--- /dev/null
+++ b/ios/Shared/MeshMapperWatchPayload.swift
@@ -0,0 +1,418 @@
+import Foundation
+
+/// Wire contract between the iPhone app and the watchOS companion.
+///
+/// Shared source: this file is compiled into both Runner and MeshMapperWatch,
+/// so the two can never drift. Dart builds the equivalent JSON in
+/// `lib/services/watch/watch_models.dart`; the golden fixtures in
+/// `test/services/watch/` are what keep Dart and Swift honest with each other.
+///
+/// Design notes that matter for battery and correctness:
+///
+/// - **Countdowns are absolute deadlines** (`phaseEndsAt`), never tick counts.
+/// The watch renders them with `Text(timerInterval:)`, so an active session
+/// sends roughly one update per phase transition rather than one per second.
+/// - **Colours are resolved on the phone.** Dart owns the colour-vision
+/// palettes, so the watch receives sRGB components and stays dumb. This is
+/// why accessibility palettes work on the wrist for free.
+/// - **Everything is capped.** WatchConnectivity payloads should stay small;
+/// the wrist is a glance surface and the phone is where the full history is.
+enum MeshMapperWatchWire {
+ /// Bump when a field changes meaning or is removed. The receiver refuses
+ /// payloads it doesn't understand rather than rendering something wrong.
+ ///
+ /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and
+ /// ping-type colour — instead of richer per-echo data. Hop counts are gone:
+ /// the overlay is fed direct repeaters only.
+ ///
+ /// Additive optional fields stay on v2: this decoder defaults an older
+ /// phone's missing mode list to Passive and missing Ping applicability to
+ /// false, while an older phone safely ignores a command's new mode field.
+ /// Rejecting that pair would add no protection.
+ static let version = 2
+
+ /// Caps, mirrored in Dart. Enforced on send *and* validated on receive.
+ static let maxPings = 60
+ static let maxRepeaters = 20
+
+ /// Three top-SNR rows plus the RX slot.
+ static let maxHeard = 4
+}
+
+// MARK: - Colour
+
+/// An sRGB colour resolved by Dart from the active colour-vision palette.
+struct WatchColor: Codable, Hashable {
+ let r: Double
+ let g: Double
+ let b: Double
+}
+
+// MARK: - Geo
+
+struct WatchPosition: Codable, Hashable {
+ let lat: Double
+ let lon: Double
+ /// Degrees clockwise from true north; nil when the fix has no course.
+ let headingDeg: Double?
+ let accuracyM: Double?
+ /// Milliseconds since epoch, so the watch can age the fix itself.
+ let fixedAtMs: Double
+}
+
+/// A ping marker, already coloured by outcome.
+struct WatchPing: Codable, Hashable, Identifiable {
+ let id: String
+ let lat: Double
+ let lon: Double
+ /// "tx" | "rx" | "disc" | "trace" — for glyph choice, not colour.
+ let kind: String
+ let color: WatchColor
+ let atMs: Double
+}
+
+/// A repeater pin. `heardThisCycle` drives the highlight ring.
+struct WatchRepeater: Codable, Hashable, Identifiable {
+ let id: String
+ /// Full repeater hex. Heard path hashes are prefixes of this value; the API
+ /// database ID above belongs to a different identity domain.
+ // Optional only for one-version migration: a new watch can receive the
+ // phone's previously persisted v2 application context before the matching
+ // app update replaces it. New Dart payloads always provide this field.
+ let hexId: String?
+ let name: String
+ let lat: Double
+ let lon: Double
+ let color: WatchColor
+ let heardThisCycle: Bool
+}
+
+/// One row of the "Top Heard" overlay.
+///
+/// Mirrors `_buildTopRepeatersOverlay` on the phone's map: a dot coloured by
+/// the kind of ping answered, the hex path-hash ID, and the SNR.
+///
+/// The **hex ID is the identity**. Path hashes are 1–3 bytes, so a short ID
+/// often maps to more than one repeater; `name` arrives only when the match is
+/// unambiguous and is shown as a secondary hint, never in place of the ID.
+///
+/// No hop count by design — the overlay is fed direct repeaters only.
+struct WatchHeardNode: Codable, Hashable, Identifiable {
+ /// Uppercase hex, 2/4/6 characters depending on the zone's hop bytes.
+ let id: String
+ let name: String?
+ let snr: Double?
+ let atMs: Double
+ let distanceM: Double?
+ /// SNR traffic-light colour, resolved by Dart.
+ let snrColor: WatchColor?
+ /// Ping type answered: green flood/active, teal discovery, cyan trace,
+ /// purple most-recent RX.
+ let typeColor: WatchColor
+}
+
+struct WatchGeo: Codable, Hashable {
+ let you: WatchPosition?
+ let pings: [WatchPing]
+ let repeaters: [WatchRepeater]
+ let heard: [WatchHeardNode]
+ /// Repeater IDs the last ping reached, for the optional map lines.
+ let linkedRepeaterIds: [String]
+}
+
+// MARK: - Controls
+
+/// What the wrist is allowed to do right now.
+///
+/// These drive button enablement only. The phone revalidates every command
+/// against the same guards the in-app buttons use, so a stale payload can
+/// never talk the phone into an illegal transmit.
+struct WatchControls: Codable, Hashable {
+ let canStartStop: Bool
+ let canManualPing: Bool
+ let isSessionActive: Bool
+ /// Stable slot ownership, separate from transient ping enablement.
+ let manualPingApplicable: Bool
+ /// Absolute deadline for the 15 s manual cooldown, if one is running.
+ let manualCooldownEndsAtMs: Double?
+ /// Human-readable reason a control is unavailable ("Not connected").
+ let blockedReason: String?
+
+ init(
+ canStartStop: Bool,
+ canManualPing: Bool,
+ isSessionActive: Bool,
+ manualPingApplicable: Bool = false,
+ manualCooldownEndsAtMs: Double?,
+ blockedReason: String?
+ ) {
+ self.canStartStop = canStartStop
+ self.canManualPing = canManualPing
+ self.isSessionActive = isSessionActive
+ self.manualPingApplicable = manualPingApplicable
+ self.manualCooldownEndsAtMs = manualCooldownEndsAtMs
+ self.blockedReason = blockedReason
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case canStartStop, canManualPing, isSessionActive
+ case manualPingApplicable, manualCooldownEndsAtMs, blockedReason
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ canStartStop = try values.decode(Bool.self, forKey: .canStartStop)
+ canManualPing = try values.decode(Bool.self, forKey: .canManualPing)
+ isSessionActive = try values.decode(Bool.self, forKey: .isSessionActive)
+ // Additive v2 field. An older phone cannot promise stable Ping ownership,
+ // so preserving the established Stop slot is the conservative default.
+ manualPingApplicable = try values.decodeIfPresent(
+ Bool.self,
+ forKey: .manualPingApplicable
+ ) ?? false
+ manualCooldownEndsAtMs = try values.decodeIfPresent(
+ Double.self,
+ forKey: .manualCooldownEndsAtMs
+ )
+ blockedReason = try values.decodeIfPresent(String.self, forKey: .blockedReason)
+ }
+}
+
+// MARK: - Haptics
+
+/// A one-shot event the watch should feel.
+///
+/// Carries an `id` so the watch fires exactly once per event: state diffing
+/// would double-fire on redelivery, which WatchConnectivity does routinely.
+struct WatchHapticCue: Codable, Hashable {
+ let id: String
+ /// "success" | "failure" | "notification"
+ let kind: String
+ /// Optional for migration, but new phones always send it. A watch must not
+ /// replay an undated cue retained by an older application context.
+ let issuedAtMs: Double?
+ /// Optional is an additive wire change: v2 payloads without it still decode,
+ /// and the matched phone and watch targets ship the new field together.
+ let message: String?
+
+ var issuedAt: Date? {
+ issuedAtMs.map { Date(timeIntervalSince1970: $0 / 1000) }
+ }
+}
+
+// MARK: - Snapshot
+
+/// The complete state the watch renders.
+struct WatchSnapshot: Codable, Hashable {
+ let wireVersion: Int
+ let sessionId: String
+
+ // Session core — mirrors the Live Activity's fields so both surfaces agree.
+ let mode: String
+ let phase: String
+ let phaseTitle: String
+ let phaseDetail: String?
+ let phaseEndsAtMs: Double?
+ /// Total length of the current phase, so the watch can draw a depleting bar
+ /// locally. Absent when no countdown owns the deadline.
+ let phaseDurationMs: Int?
+ let isConnected: Bool
+ let zoneCode: String?
+ let txCount: Int
+ let rxCount: Int
+ let discoveryCount: Int
+ let traceCount: Int
+ let queueSize: Int
+
+ /// Colour of the most recent completed ping result.
+ let pingColor: WatchColor?
+
+ /// Lowercase mode names the phone currently permits for a wrist start.
+ /// This is policy resolved by the phone, not enough raw state for the watch
+ /// to derive policy independently.
+ let availableStartModes: [String]
+
+ /// False means the map-only arrays were intentionally cleared. Missing on
+ /// older additive-v2 payloads means full geography, the fail-safe default.
+ let mapGeoIncluded: Bool
+ let geo: WatchGeo
+ let controls: WatchControls
+ let cue: WatchHapticCue?
+ let updatedAtMs: Double
+
+ init(
+ wireVersion: Int,
+ sessionId: String,
+ mode: String,
+ phase: String,
+ phaseTitle: String,
+ phaseDetail: String?,
+ phaseEndsAtMs: Double?,
+ phaseDurationMs: Int?,
+ isConnected: Bool,
+ zoneCode: String?,
+ txCount: Int,
+ rxCount: Int,
+ discoveryCount: Int,
+ traceCount: Int,
+ queueSize: Int,
+ pingColor: WatchColor?,
+ availableStartModes: [String] = ["passive"],
+ mapGeoIncluded: Bool = true,
+ geo: WatchGeo,
+ controls: WatchControls,
+ cue: WatchHapticCue?,
+ updatedAtMs: Double
+ ) {
+ self.wireVersion = wireVersion
+ self.sessionId = sessionId
+ self.mode = mode
+ self.phase = phase
+ self.phaseTitle = phaseTitle
+ self.phaseDetail = phaseDetail
+ self.phaseEndsAtMs = phaseEndsAtMs
+ self.phaseDurationMs = phaseDurationMs
+ self.isConnected = isConnected
+ self.zoneCode = zoneCode
+ self.txCount = txCount
+ self.rxCount = rxCount
+ self.discoveryCount = discoveryCount
+ self.traceCount = traceCount
+ self.queueSize = queueSize
+ self.pingColor = pingColor
+ self.availableStartModes = availableStartModes
+ self.mapGeoIncluded = mapGeoIncluded
+ self.geo = geo
+ self.controls = controls
+ self.cue = cue
+ self.updatedAtMs = updatedAtMs
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case wireVersion, sessionId, mode, phase, phaseTitle, phaseDetail
+ case phaseEndsAtMs, phaseDurationMs, isConnected, zoneCode
+ case txCount, rxCount, discoveryCount, traceCount, queueSize, pingColor
+ case availableStartModes, mapGeoIncluded, geo, controls, cue, updatedAtMs
+ }
+
+ init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ wireVersion = try values.decode(Int.self, forKey: .wireVersion)
+ sessionId = try values.decode(String.self, forKey: .sessionId)
+ mode = try values.decode(String.self, forKey: .mode)
+ phase = try values.decode(String.self, forKey: .phase)
+ phaseTitle = try values.decode(String.self, forKey: .phaseTitle)
+ phaseDetail = try values.decodeIfPresent(String.self, forKey: .phaseDetail)
+ phaseEndsAtMs = try values.decodeIfPresent(Double.self, forKey: .phaseEndsAtMs)
+ phaseDurationMs = try values.decodeIfPresent(Int.self, forKey: .phaseDurationMs)
+ isConnected = try values.decode(Bool.self, forKey: .isConnected)
+ zoneCode = try values.decodeIfPresent(String.self, forKey: .zoneCode)
+ txCount = try values.decode(Int.self, forKey: .txCount)
+ rxCount = try values.decode(Int.self, forKey: .rxCount)
+ discoveryCount = try values.decode(Int.self, forKey: .discoveryCount)
+ traceCount = try values.decode(Int.self, forKey: .traceCount)
+ queueSize = try values.decode(Int.self, forKey: .queueSize)
+ pingColor = try values.decodeIfPresent(WatchColor.self, forKey: .pingColor)
+ // Additive v2 field: an older phone omits it, and Passive is the only mode
+ // safe to promise without current phone-resolved zone policy. Keeping v2
+ // avoids stranding otherwise compatible phone/watch pairs.
+ availableStartModes = try values.decodeIfPresent(
+ [String].self,
+ forKey: .availableStartModes
+ ) ?? ["passive"]
+ // Additive v2 field. An old phone always sends full geography, so absence
+ // must mean included; treating it as suppressed could blank a real map.
+ mapGeoIncluded = try values.decodeIfPresent(
+ Bool.self,
+ forKey: .mapGeoIncluded
+ ) ?? true
+ geo = try values.decode(WatchGeo.self, forKey: .geo)
+ controls = try values.decode(WatchControls.self, forKey: .controls)
+ cue = try values.decodeIfPresent(WatchHapticCue.self, forKey: .cue)
+ updatedAtMs = try values.decode(Double.self, forKey: .updatedAtMs)
+ }
+
+ /// True when this payload came from a wire version the app understands.
+ var isSupportedVersion: Bool { wireVersion == MeshMapperWatchWire.version }
+
+ var updatedAt: Date {
+ Date(timeIntervalSince1970: updatedAtMs / 1000)
+ }
+
+ var phaseEndsAt: Date? {
+ phaseEndsAtMs.map { Date(timeIntervalSince1970: $0 / 1000) }
+ }
+
+ /// Fraction of the current phase still to run, 0...1.
+ ///
+ /// Computed from absolute values at render time, so it stays correct between
+ /// updates and when the app opens midway through a phase. Nil when no
+ /// countdown owns the deadline, in which case no bar is drawn.
+ func phaseRemainingFraction(at now: Date = Date()) -> Double? {
+ guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { return nil }
+ let remaining = phaseEndsAt.timeIntervalSince(now)
+ guard remaining > 0 else { return 0 }
+ return min(1, remaining / (Double(phaseDurationMs) / 1000))
+ }
+}
+
+// MARK: - Commands (watch → phone)
+
+/// An intent from the wrist. Never state — the phone decides what happens.
+struct WatchCommand: Codable, Hashable {
+ enum Kind: String, Codable {
+ case startSession
+ case stopSession
+ case manualPing
+ /// Watch asking for a fresh snapshot (e.g. app just came to the front).
+ case requestSnapshot
+ }
+
+ let kind: Kind
+ /// Optional additive field. Older phones ignore it and retain their safe
+ /// mode resolver; new phones revalidate it instead of silently downgrading.
+ let mode: String?
+ /// Optional map-demand state carried only by requestSnapshot. An old phone
+ /// ignores it and keeps sending full geography, so wire v2 remains safe.
+ let mapGeoNeeded: Bool?
+ /// Whether this is a genuine plea for current state, rather than a change of
+ /// map demand that happens to travel as the same command.
+ ///
+ /// `requestSnapshot` carries both intents. Only the first should defeat the
+ /// phone's unchanged-state dedupe: the map-geo lease renews every five
+ /// minutes for as long as the map stays hidden, and forcing an identical
+ /// snapshot each time would spend the radio precisely where the lease exists
+ /// to save it. The distinction is stated rather than inferred from
+ /// `mapGeoNeeded`, which the phone may legitimately resolve to nil when a
+ /// suppression claim arrives stale or out of order.
+ ///
+ /// Optional for the usual reason: absent means false, so a phone paired with
+ /// an older watch build behaves exactly as it did before.
+ let forceRefresh: Bool?
+ /// Client-generated, so the phone can dedupe redelivered commands.
+ let id: String
+ /// Queued delivery can outlive the place where a transmit was requested.
+ /// The phone uses this to reject stale actions before admission.
+ let issuedAtMs: Double
+}
+
+// There is deliberately no acknowledgement model: queued commands have no
+// reply channel; state snapshots and failure cues carry every outcome.
+
+// MARK: - Coding helpers
+
+extension MeshMapperWatchWire {
+ static let encoder: JSONEncoder = {
+ let encoder = JSONEncoder()
+ return encoder
+ }()
+
+ static let decoder: JSONDecoder = {
+ let decoder = JSONDecoder()
+ return decoder
+ }()
+
+ /// Key used for the single `Data` blob inside a WatchConnectivity payload.
+ static let payloadKey = "snapshot"
+ static let commandKey = "command"
+}
diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart
index 059dda5..185fbfd 100644
--- a/lib/providers/app_state_provider.dart
+++ b/lib/providers/app_state_provider.dart
@@ -45,6 +45,11 @@ import '../services/meshcore/tx_tracker.dart';
import '../services/meshcore/unified_rx_handler.dart';
import '../services/ping_service.dart';
import '../services/countdown_timer_service.dart';
+import '../services/live_activity/live_activity_models.dart';
+import '../services/live_activity/live_activity_service.dart';
+import '../services/watch/watch_bridge_service.dart';
+import '../services/watch/watch_geo_builder.dart';
+import '../services/watch/watch_models.dart';
import '../services/custom_api_service.dart';
import '../utils/constants.dart';
import '../utils/geo_validation.dart';
@@ -70,6 +75,8 @@ enum AutoMode {
/// Ping type for the top-heard overlay dots
enum OverlayPingType { tx, disc, trace, rx }
+enum _LiveActivityOperation { sending, discovering, tracing }
+
/// Result of uploading an offline session
enum OfflineUploadResult {
/// Upload completed successfully
@@ -124,6 +131,29 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
late final DiscoveryWindowTimer
_discoveryWindowTimer; // Discovery listening window (Passive Mode)
late final Listenable _timerListenable;
+
+ final LiveActivityService _liveActivityService = LiveActivityService();
+ final WatchBridgeService _watchBridge = WatchBridgeService();
+ bool _hasEverPairedWatch = false;
+
+ /// Last position handed to the watch, kept so a dropped GPS fix leaves the
+ /// puck where it was rather than removing it. See [_resolveWatchPosition].
+ WatchPosition? _lastWatchPosition;
+
+ /// Held position behind every distance and ordering decision in the payload.
+ /// See [_resolveRankingPosition] for why this one still lags on purpose.
+ ({double lat, double lon})? _rankingPosition;
+ WatchHapticCue? _watchCue;
+
+ /// Human-readable failure from the most recent server-side session check.
+ /// The bool returned by that check controls the action; this preserves the
+ /// discarded explanation for a wrist action's later failure cue.
+ String? _lastSessionCheckFailureReason;
+ bool _liveActivitySessionActive = false;
+ bool _liveActivityManualSession = false;
+ String? _liveActivitySessionId;
+ DateTime? _liveActivityCycleStartedAt;
+ _LiveActivityOperation? _liveActivityOperation;
MeshCoreConnection? _meshCoreConnection;
PingService? _pingService;
UnifiedRxHandler? _unifiedRxHandler;
@@ -219,9 +249,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
// Top repeaters overlay — updated live on each ping event
List<({String repeaterId, double snr, OverlayPingType type})>
_topRepeatersOverlay = [];
+ DateTime? _topRepeatersOverlayUpdatedAt;
({String repeaterId, double snr})? _rxOverlaySlot;
Timer? _rxOverlayWindowTimer;
+ // Live Activity repeater snapshot. Kept separate from the map overlay so the
+ // system presentation cannot change existing in-app overlay behaviour.
+ List<({String repeaterId, double snr, OverlayPingType type})>
+ _liveActivityRepeaters = [];
+ int _liveActivityRepeaterTotalCount = 0;
+ DateTime? _liveActivityRepeatersUpdatedAt;
+ DateTime? _liveActivityRxUpdatedAt;
+
// Targeted mode state
String? _targetRepeaterId;
@@ -349,6 +388,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
Timer? _zoneGracePollingTimer; // 5-second zone polling
Timer? _zoneGraceCountdownTimer; // 1-second UI countdown tick
int _zoneGraceSecondsRemaining = 0;
+ DateTime? _zoneGraceEndsAt;
bool _autoPingWasEnabledBeforeGrace = false;
AutoMode _autoModeBeforeGrace = AutoMode.active;
static const Duration _zoneGraceTimeout = Duration(minutes: 5);
@@ -520,6 +560,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
String get deviceId => _deviceId;
bool get preferencesLoaded => _preferencesLoaded;
+ WatchBridgeService get watchBridge => _watchBridge;
+ bool get shouldShowWatchDiagnostics =>
+ _watchBridge.isSupportedPlatform &&
+ (_watchBridge.diagnostics.value.paired || _hasEverPairedWatch);
TransportType get selectedTransport => _selectedTransport;
ConnectionStatus get connectionStatus => _connectionStatus;
ConnectionStep get connectionStep => _connectionStep;
@@ -596,6 +640,30 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
.toList()
..sort((a, b) => b.snr.compareTo(a.snr));
_topRepeatersOverlay = fresh.take(3).toList();
+ _topRepeatersOverlayUpdatedAt = DateTime.now();
+ }
+
+ void _updateLiveActivityRepeaters(
+ Iterable<({String repeaterId, double snr})> current,
+ OverlayPingType type) {
+ final bestSnr = {};
+ for (final repeater in current) {
+ if (!repeater.snr.isFinite) continue;
+ final id = repeater.repeaterId.toUpperCase();
+ final previous = bestSnr[id];
+ if (previous == null || repeater.snr > previous) {
+ bestSnr[id] = repeater.snr;
+ }
+ }
+
+ final sorted = bestSnr.entries
+ .map((entry) =>
+ (repeaterId: entry.key, snr: entry.value, type: type))
+ .toList()
+ ..sort((a, b) => b.snr.compareTo(a.snr));
+ _liveActivityRepeaters = sorted.take(3).toList(growable: false);
+ _liveActivityRepeaterTotalCount = sorted.length;
+ _liveActivityRepeatersUpdatedAt = DateTime.now();
}
/// Update the RX overlay slot — window matches auto-ping interval (best SNR wins).
@@ -604,9 +672,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
if (_rxOverlayWindowTimer?.isActive ?? false) {
if (_rxOverlaySlot == null || snr > _rxOverlaySlot!.snr) {
_rxOverlaySlot = entry;
+ _liveActivityRxUpdatedAt = DateTime.now();
}
} else {
_rxOverlaySlot = entry;
+ _liveActivityRxUpdatedAt = DateTime.now();
_rxOverlayWindowTimer =
Timer(Duration(seconds: _preferences.autoPingInterval), () {
// Window closed — slot stays until next RX or cleared
@@ -617,9 +687,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
/// Clear all overlay state (top 3 + RX slot).
void _clearOverlayState() {
_topRepeatersOverlay = [];
+ _topRepeatersOverlayUpdatedAt = null;
_rxOverlaySlot = null;
_rxOverlayWindowTimer?.cancel();
_rxOverlayWindowTimer = null;
+ _liveActivityRepeaters = [];
+ _liveActivityRepeaterTotalCount = 0;
+ _liveActivityRepeatersUpdatedAt = null;
+ _liveActivityRxUpdatedAt = null;
}
List get txLogEntries => List.unmodifiable(_txLogEntries);
@@ -1072,6 +1147,1031 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_discoveryWindowTimer; // Discovery listening window (Passive Mode)
Listenable get timerListenable => _timerListenable;
+ void _handleLiveActivityTimerChange() {
+ if (_liveActivityManualSession &&
+ !_isPingSending &&
+ !_rxWindowTimer.isRunning &&
+ !_manualPingCooldownTimer.isRunning) {
+ _finishLiveActivitySession();
+ return;
+ }
+ _scheduleLiveActivitySync();
+ }
+
+ void _startLiveActivitySession({bool manual = false}) {
+ if (!_liveActivityService.isSupportedPlatform) return;
+ if (_liveActivitySessionActive) {
+ // Starting an automatic mode while a manual-ping activity is still in
+ // cooldown upgrades the existing activity instead of creating a second.
+ if (!manual && _liveActivityManualSession) {
+ _liveActivityManualSession = false;
+ _scheduleLiveActivitySync(immediate: true);
+ }
+ return;
+ }
+ _liveActivitySessionActive = true;
+ _liveActivityManualSession = manual;
+ _liveActivitySessionId = const Uuid().v4();
+ _liveActivityCycleStartedAt = _activeLiveActivityCycleStartedAt;
+ _liveActivityOperation = null;
+ _scheduleLiveActivitySync(immediate: true);
+ }
+
+ DateTime? get _activeLiveActivityCycleStartedAt {
+ if (_rxWindowTimer.isRunning && _txLogEntries.isNotEmpty) {
+ return _txLogEntries.last.timestamp;
+ }
+ if (!_discoveryWindowTimer.isRunning) return null;
+ if (_autoMode == AutoMode.targeted && _traceLogEntries.isNotEmpty) {
+ return _traceLogEntries.first.timestamp;
+ }
+ if (_discLogEntries.isNotEmpty) {
+ return _discLogEntries.first.timestamp;
+ }
+ return null;
+ }
+
+ void _finishLiveActivitySession() {
+ if (!_liveActivitySessionActive) return;
+ _liveActivitySessionActive = false;
+ _liveActivityManualSession = false;
+ _liveActivityOperation = null;
+ _liveActivityCycleStartedAt = null;
+ _scheduleLiveActivitySync(immediate: true);
+ _liveActivitySessionId = null;
+ }
+
+ void _markLiveActivityOperation(_LiveActivityOperation operation) {
+ if (!_liveActivitySessionActive ||
+ !_liveActivityService.isSupportedPlatform) {
+ return;
+ }
+ final now = DateTime.now();
+ _liveActivityOperation = operation;
+ _liveActivityCycleStartedAt = now;
+ _scheduleLiveActivitySync(immediate: true);
+ }
+
+ void _scheduleLiveActivitySync({bool immediate = false}) {
+ if (_isDisposed) return;
+ // The watch mirrors state even with no session running — otherwise you
+ // could never start one from the wrist.
+ _scheduleWatchSync(immediate: immediate);
+ if (!_liveActivityService.isSupportedPlatform) return;
+ _liveActivityService.schedule(
+ _buildLiveActivitySnapshot,
+ immediate: immediate,
+ );
+ }
+
+ void _scheduleWatchSync({bool immediate = false, bool forceDelivery = false}) {
+ if (_isDisposed || !_watchBridge.canSync) return;
+ _watchBridge.schedule(
+ _buildWatchSnapshot,
+ urgencyKeyBuilder: _buildWatchUrgencyKey,
+ immediate: immediate,
+ forceDelivery: forceDelivery,
+ );
+ }
+
+ /// The bridge needs to decide whether a flush can wait before it builds the
+ /// geographic payload. Keep this in the wire model's shared formatter so the
+ /// cheap preflight and the eventual snapshot cannot drift on what is urgent.
+ String _buildWatchUrgencyKey() {
+ final phase = _resolveWatchPhase();
+ final controls = _buildWatchControls();
+ return WatchSnapshot.buildUrgencyKey(
+ sessionId: _liveActivitySessionId ?? 'idle',
+ mode: _resolvedWatchSessionModeTitle,
+ phase: phase.phase,
+ phaseTitle: phase.title,
+ phaseDetail: phase.detail,
+ phaseEndsAt: phase.endsAt,
+ isConnected: isConnected,
+ controls: controls,
+ cue: _watchCue,
+ mapGeoIncluded: _watchBridge.shouldIncludeMapGeo,
+ );
+ }
+
+ /// Builds the watch payload.
+ ///
+ /// Unlike the Live Activity, this is never null while the app is alive: the
+ /// wrist shows idle and disconnected states too, and the start button has to
+ /// be reachable before a session exists.
+ WatchSnapshot? _buildWatchSnapshot() {
+ if (_isDisposed) return null;
+
+ final phase = _resolveWatchPhase();
+ final repeaterState = _buildLiveActivityRepeaters();
+ final now = DateTime.now();
+ final phaseDurationMs = _phaseDurationMsFor(phase.endsAt);
+ final pingColor = _resolveWatchPingColor();
+ final includeMapGeo = _watchBridge.shouldIncludeMapGeo;
+
+ final core = LiveActivitySnapshot(
+ sessionId: _liveActivitySessionId ?? 'idle',
+ // On the watch this field is also the Start button's promise, so it must
+ // describe the resolver the command will use rather than the ambient
+ // default that only becomes meaningful after a phone button is pressed.
+ mode: _resolvedWatchSessionModeTitle,
+ phase: phase.phase,
+ phaseTitle: phase.title,
+ phaseDetail: phase.detail,
+ phaseEndsAt: phase.endsAt,
+ phaseDurationMs: phaseDurationMs,
+ pingColor: pingColor,
+ isConnected: isConnected,
+ zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode,
+ txCount: _pingStats.txCount,
+ rxCount: _pingStats.rxCount,
+ discoveryCount: _pingStats.discCount,
+ traceCount: _pingStats.traceCount,
+ queueSize: _queueSize,
+ repeaters: repeaterState.repeaters,
+ totalHeardCount: repeaterState.totalCount,
+ repeatersAreCurrent: repeaterState.isCurrent,
+ updatedAt: now,
+ );
+
+ return WatchSnapshot(
+ core: core,
+ geo: _buildWatchGeo(includeMapGeo: includeMapGeo),
+ controls: _buildWatchControls(),
+ mapGeoIncluded: includeMapGeo,
+ availableStartModes: _availableWatchStartModes,
+ pingColor: pingColor,
+ cue: _watchCue,
+ phaseDurationMs: phaseDurationMs,
+ updatedAt: now,
+ );
+ }
+
+ /// Total length of the countdown that owns [endsAt].
+ ///
+ /// The phase resolver returns a deadline without saying which timer produced
+ /// it, so the owner is identified by matching end times. Returns null for
+ /// deadlines no countdown owns (the zone grace period), in which case the
+ /// watch shows the remaining time without a progress bar.
+ int? _phaseDurationMsFor(DateTime? endsAt) {
+ if (endsAt == null) return null;
+ for (final timer in [
+ _autoPingTimer,
+ _rxWindowTimer,
+ _discoveryWindowTimer,
+ _manualPingCooldownTimer,
+ _cooldownTimer,
+ ]) {
+ if (timer.isRunning && timer.endTime == endsAt) {
+ return timer.durationMs;
+ }
+ }
+ return null;
+ }
+
+ WatchGeo _buildWatchGeo({required bool includeMapGeo}) {
+ final position = _resolveWatchPosition();
+ final ranking = _resolveRankingPosition();
+
+ // The wrist mirrors the map's "Top Heard" overlay: the latest ping's top
+ // three by SNR plus the current RX slot. Same source, so the two surfaces
+ // can never disagree.
+ final top = _topRepeatersOverlay;
+ final rxSlot = _rxOverlaySlot;
+
+ // Overlay IDs are hex path hashes, so resolve names by prefix at whatever
+ // length this zone actually uses.
+ final hexLength = top.isNotEmpty
+ ? top.first.repeaterId.length
+ : (rxSlot?.repeaterId.length ?? 0);
+ final repeaterByHex = hexLength > 0
+ ? WatchGeoBuilder.indexByHexPrefix(_repeaters, hexLength)
+ : const {};
+ final heard = WatchGeoBuilder.buildHeard(
+ top: top,
+ rxSlot: rxSlot,
+ repeaterByHex: repeaterByHex,
+ topAt: _topRepeatersOverlayUpdatedAt,
+ rxAt: _liveActivityRxUpdatedAt,
+ lat: ranking?.lat,
+ lon: ranking?.lon,
+ );
+
+ // The readout still needs the fix and Top Heard, but none of the arrays
+ // below. Return before merging and sorting four ping histories or sorting
+ // the repeater catalogue: suppression is meant to save phone work as well
+ // as radio bytes.
+ if (!includeMapGeo) {
+ return WatchGeo(
+ you: position,
+ pings: const [],
+ repeaters: const [],
+ heard: heard,
+ linkedRepeaterIds: const [],
+ );
+ }
+
+ // Repeaters heard during the current cycle get the highlight ring.
+ final heardIds = top.map((r) => r.repeaterId.toUpperCase()).toSet();
+ if (rxSlot != null) heardIds.add(rxSlot.repeaterId.toUpperCase());
+
+ return WatchGeo(
+ you: position,
+ pings: WatchGeoBuilder.buildPings(
+ txPings: _txPings,
+ rxPings: _rxPings,
+ discLogEntries: _discLogEntries,
+ traceLogEntries: _traceLogEntries,
+ ),
+ repeaters: WatchGeoBuilder.buildRepeaters(
+ repeaters: _repeaters,
+ heardThisCycle: heardIds,
+ lat: ranking?.lat,
+ lon: ranking?.lon,
+ ),
+ heard: heard,
+ linkedRepeaterIds: [
+ ...WatchGeoBuilder.resolveUniqueHexPrefixes(
+ repeaters: _repeaters,
+ prefixes: heardIds,
+ ).keys,
+ ],
+ );
+ }
+
+ /// The current fix, reported as it is.
+ ///
+ /// **The movement gate used to live here and deliberately does not any
+ /// more.** It returned the *previous* position until the fix had moved
+ /// [WatchWire.minMoveMeters], which left the payload fingerprint unchanged so
+ /// the bridge's dedupe suppressed the send — that is how a parked phone stops
+ /// streaming GPS jitter at the watch, and it is still how it works. But
+ /// suppressing a *send* by degrading the *content* also degrades every send
+ /// that happens for some other reason. A new ping changes the payload
+ /// regardless, so the packet goes out carrying a puck up to 15 m stale while
+ /// the ping beside it carries its own transmit-time GPS. The wearer sees the
+ /// pings leading them in the direction of travel.
+ ///
+ /// The gate now sits in [WatchBridgeService], which asks "is this change
+ /// worth a send?" without touching what gets sent — and in
+ /// [_resolveRankingPosition], which keeps every *derived* field as still as
+ /// it was before. Only the puck moved.
+ WatchPosition? _resolveWatchPosition() {
+ final position = _currentPosition;
+ if (position == null) return _lastWatchPosition;
+
+ final resolved = WatchPosition(
+ lat: position.latitude,
+ lon: position.longitude,
+ headingDeg: position.heading.isFinite && position.heading >= 0
+ ? position.heading
+ : null,
+ accuracyM: position.accuracy.isFinite ? position.accuracy : null,
+ fixedAt: position.timestamp,
+ );
+ _lastWatchPosition = resolved;
+ return resolved;
+ }
+
+ /// Position used to rank repeaters and to measure Top Heard distances, held
+ /// still until the fix moves [WatchWire.minMoveMeters].
+ ///
+ /// **This is the old gate, kept exactly where it still belongs.** It was
+ /// removed from the puck because a stale puck is visibly wrong beside a ping
+ /// carrying its own GPS. Everything derived from position has the opposite
+ /// requirement: `WatchHeardNode.distanceM` is a full-precision double over a
+ /// distance measured in kilometres, and the nearest-first repeater order can
+ /// swap on a metre. Feeding those the live fix would make a parked phone's
+ /// GPS jitter change the payload every time, which is precisely the send the
+ /// bridge's gate exists to suppress — and it would slip past that gate,
+ /// because the gate can only recognise a change confined to the fix itself.
+ ///
+ /// Fifteen metres of staleness is invisible in a distance readout and cannot
+ /// meaningfully reorder repeaters. The puck is the only place it showed.
+ ({double lat, double lon})? _resolveRankingPosition() {
+ final position = _currentPosition;
+ if (position == null) return _rankingPosition;
+
+ final previous = _rankingPosition;
+ if (previous != null &&
+ !WatchWire.movedEnough(
+ lastLat: previous.lat,
+ lastLon: previous.lon,
+ lat: position.latitude,
+ lon: position.longitude,
+ )) {
+ return previous;
+ }
+
+ final resolved = (lat: position.latitude, lon: position.longitude);
+ _rankingPosition = resolved;
+ return resolved;
+ }
+
+ ({bool allowed, String? reason}) get _manualPingAvailability {
+ // This must remain the sole copy of the app button's gate. One caller says
+ // what the wrist may offer while the other decides whether the radio may
+ // transmit; letting those answers drift makes a stale watch payload unsafe.
+ final canPingManual = manualPingValidation == PingValidation.valid;
+ final isAutoStarting = isAutoPingStarting;
+ final isTxModeActive = isTxModeRunning;
+ final isTargetedRunning = isTargetedModeRunning;
+ final cooldownActive = cooldownTimer.isRunning;
+ final manualCooldownActive = manualPingCooldownTimer.isRunning;
+ final txBlockedByOffline = offlineMode && isConnected;
+ final txNotAllowed = isConnected && !txAllowed;
+ final rxWindowActive = rxWindowTimer.isRunning;
+ final pingSending = isPingSending;
+ final discoveryWindowActive = discoveryWindowTimer.isRunning;
+ final pendingDisable = isPendingDisable;
+ final allowed = canPingManual &&
+ !isAutoStarting &&
+ !isTxModeActive &&
+ !isTargetedRunning &&
+ !cooldownActive &&
+ !manualCooldownActive &&
+ !txBlockedByOffline &&
+ !txNotAllowed &&
+ !rxWindowActive &&
+ !pingSending &&
+ !discoveryWindowActive &&
+ !pendingDisable;
+
+ // Only describe a refusal that is actually happening. A reason computed
+ // alongside an allowed ping would surface on the wrist as a status line
+ // under two working buttons.
+ final String? reason;
+ if (allowed) {
+ reason = null;
+ } else if (!isConnected) {
+ reason = 'Not connected';
+ } else if (!hasGpsLock) {
+ reason = 'No GPS fix';
+ } else if (txBlockedByOffline) {
+ reason = 'Offline Mode';
+ } else if (txNotAllowed) {
+ reason = 'Passive Only';
+ } else if (manualPingValidation == PingValidation.manualCooldownActive ||
+ cooldownActive ||
+ manualCooldownActive ||
+ rxWindowActive ||
+ discoveryWindowActive) {
+ reason = 'Cooling down';
+ } else if (!canPingManual) {
+ reason = manualPingValidation.message;
+ } else {
+ reason = 'Another operation is in progress';
+ }
+
+ return (allowed: allowed, reason: reason);
+ }
+
+ AutoMode get _resolvedWatchSessionMode {
+ // Phone buttons each carry an explicit mode, but the wrist has one generic
+ // Start button and `_autoMode` begins as Active before any phone choice has
+ // established intent. In a passive-only region inheriting that default
+ // silently selects the one forbidden mode. Once running, preserve the
+ // actual mode so the same resolver always stops what it started.
+ if (_autoPingEnabled) return _autoMode;
+ if (isConnected && !txAllowed) return AutoMode.passive;
+ return _autoMode;
+ }
+
+ List get _availableWatchStartModes => [
+ WatchStartMode.passive,
+ if (isConnected && txAllowed) WatchStartMode.hybrid,
+ ];
+
+ String get _resolvedWatchSessionModeTitle =>
+ switch (_resolvedWatchSessionMode) {
+ AutoMode.active => 'Active',
+ AutoMode.passive => 'Passive',
+ AutoMode.hybrid => 'Hybrid',
+ AutoMode.targeted => 'Trace',
+ };
+
+ WatchControls _buildWatchControls() {
+ final cooldownMs = _manualPingCooldownTimer.remainingMs;
+ final manualPing = _manualPingAvailability;
+ final passiveStart = sessionStartAvailability(AutoMode.passive);
+ // The wire has one Start/Stop bit while the preferred start mode lives on
+ // the watch. Passive is always advertised and is the safe fallback, so this
+ // bit answers whether at least that start is currently possible; the
+ // command handler applies the same rule again to the mode actually asked
+ // for. An active session uses the bit for Stop instead.
+ final canStartOrStop =
+ _autoPingEnabled ? isConnected : passiveStart.allowed;
+
+ return WatchControls(
+ canStartStop: canStartOrStop,
+ canManualPing: manualPing.allowed,
+ isSessionActive: _autoPingEnabled,
+ // Slot ownership must not follow the live manual-ping gate: cooldowns
+ // and receive windows would otherwise replace Ping with Stop beneath a
+ // thumb. TX sessions cannot manually ping for their entire lifetime, so
+ // they keep Stop available even in a region where TX is permitted.
+ manualPingApplicable: isConnected &&
+ txAllowed &&
+ !isTxModeRunning &&
+ !isTargetedModeRunning,
+ manualCooldownEndsAt: cooldownMs > 0
+ ? _manualPingCooldownTimer.endTime
+ : null,
+ // The button already renders its cooldown deadline. The handler still
+ // returns this refusal to a stale tap, but duplicating it as a status
+ // line would spend wrist space without adding an explanation.
+ blockedReason: !_autoPingEnabled && !passiveStart.allowed
+ ? passiveStart.reason
+ : (manualPing.reason == 'Cooling down' ? null : manualPing.reason),
+ );
+ }
+
+ /// Colour of the most recent completed coverage event, matching the marker
+ /// beside it rather than leaving Passive mode stuck on an old TX result.
+ WatchColor? _resolveWatchPingColor() => WatchGeoBuilder.latestPingColor(
+ txPings: _txPings,
+ rxPings: _rxPings,
+ discLogEntries: _discLogEntries,
+ traceLogEntries: _traceLogEntries,
+ );
+
+ /// Whether [mode] may begin now, with the reason the wearer should see.
+ ///
+ /// This is the sole provider-side start gate. One caller publishes wrist
+ /// enablement and the other admits the command that mutates session state;
+ /// separate copies let a stale or racing wrist action enter a state the phone
+ /// button itself would never offer.
+ SessionStartAvailability sessionStartAvailability(AutoMode mode) {
+ final isTransmitMode = mode != AutoMode.passive;
+ final validation =
+ isTransmitMode ? autoModeValidation : PingValidation.valid;
+ final powerConfigured = _preferences.autoPowerSet ||
+ _preferences.powerLevelSet ||
+ _deviceModel != null;
+
+ return resolveSessionStartAvailability(
+ isTransmitMode: isTransmitMode,
+ isConnected: isConnected,
+ antennaConfigured: _preferences.externalAntennaSet,
+ powerConfigured: powerConfigured,
+ isPendingDisable: isPendingDisable,
+ isTargetedRunning: isTargetedModeRunning,
+ isAutoStarting: isAutoPingStarting,
+ cooldownActive: _cooldownTimer.isRunning,
+ isPingSending: isPingSending,
+ rxWindowActive: _rxWindowTimer.isRunning,
+ txBlockedByOffline: offlineMode && isConnected,
+ txNotAllowed: isConnected && !txAllowed,
+ transmitValidationReason:
+ validation == PingValidation.valid ? null : validation.message,
+ );
+ }
+
+ /// Decides whether an intent from the wrist may begin.
+ ///
+ /// Returns null when accepted, or a reason to show on the watch. Every guard
+ /// is re-evaluated here: the watch's view of what's permitted may be stale,
+ /// and a stale payload must never be able to cause a transmit.
+ ///
+ /// Once admitted, the action deliberately outlives this synchronous reply:
+ /// WatchConnectivity cannot wait for BLE or server work. Successful outcomes
+ /// already surface through session, phase, and ping-colour snapshots; a late
+ /// failure gets its own cue so dropping completion from the ack loses nothing.
+ String? _handleWatchCommand(WatchCommand command) {
+ if (_isDisposed) return 'App closing';
+
+ final kind = command.kind;
+
+ switch (kind) {
+ case WatchCommandKind.requestSnapshot:
+ // This command carries two intents. A genuine plea for state — after a
+ // relaunch or a resume onto a retained context of unknown age — must
+ // not be answered with dedupe's silence. A change of map demand is not
+ // that, and the lease behind it renews every five minutes for as long
+ // as the map stays hidden, so forcing those would spend the radio
+ // exactly where the lease exists to save it.
+ _scheduleWatchSync(
+ immediate: true,
+ forceDelivery: command.forceRefresh,
+ );
+ return null;
+
+ case WatchCommandKind.startSession:
+ final admission = resolveWatchSessionCommandAdmission(
+ kind: kind,
+ isSessionActive: _autoPingEnabled,
+ isSessionStarting: _autoPingStarting,
+ );
+ if (admission.refusal != null) return admission.refusal;
+ if (!admission.shouldRun) return null;
+ final requested = resolveWatchRequestedStartMode(
+ requestedMode: command.mode,
+ isConnected: isConnected,
+ txAllowed: txAllowed,
+ );
+ if (requested.refusal != null) return requested.refusal;
+ final mode = switch (requested.mode) {
+ WatchStartMode.passive => AutoMode.passive,
+ WatchStartMode.hybrid => AutoMode.hybrid,
+ null => _resolvedWatchSessionMode,
+ };
+ final availability = sessionStartAvailability(mode);
+ if (!availability.allowed) {
+ return availability.reason ?? 'Could not start';
+ }
+ unawaited(_runWatchStartSession(mode));
+ return null;
+
+ case WatchCommandKind.stopSession:
+ final admission = resolveWatchSessionCommandAdmission(
+ kind: kind,
+ isSessionActive: _autoPingEnabled,
+ isSessionStarting: _autoPingStarting,
+ );
+ if (admission.refusal != null) return admission.refusal;
+ if (!admission.shouldRun) return null;
+ unawaited(_runWatchStopSession(_resolvedWatchSessionMode));
+ return null;
+
+ case WatchCommandKind.manualPing:
+ final availability = _manualPingAvailability;
+ if (!availability.allowed) {
+ return availability.reason ?? 'Ping unavailable';
+ }
+ unawaited(_runWatchManualPing());
+ return null;
+ }
+ }
+
+ Future _runWatchStartSession(AutoMode mode) async {
+ _lastSessionCheckFailureReason = null;
+ try {
+ final started = await toggleAutoPing(mode);
+ if (!started) {
+ _emitWatchFailure(_watchStartFailureReason(mode));
+ }
+ } catch (error) {
+ debugError('[WATCH] startSession failed after admission: $error');
+ _emitWatchFailure(_watchStartFailureReason(mode));
+ }
+ }
+
+ Future _runWatchStopSession(AutoMode mode) async {
+ try {
+ final stopped = await toggleAutoPing(mode);
+ if (!stopped) _emitWatchFailure('Could not stop');
+ } catch (error) {
+ debugError('[WATCH] stopSession failed after admission: $error');
+ _emitWatchFailure('Could not stop');
+ }
+ }
+
+ String _watchStartFailureReason(AutoMode mode) {
+ final sessionReason = _lastSessionCheckFailureReason;
+ if (sessionReason != null) return sessionReason;
+ if (mode != AutoMode.passive && _cooldownTimer.isRunning) {
+ return 'Cooling down';
+ }
+ return 'Could not start';
+ }
+
+ Future _runWatchManualPing() async {
+ _lastSessionCheckFailureReason = null;
+ try {
+ final sent = await sendPing();
+ if (!sent) {
+ _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Ping failed');
+ }
+ } catch (error) {
+ debugError('[WATCH] manualPing failed after admission: $error');
+ _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Ping failed');
+ }
+ }
+
+ void _emitWatchFailure(String message) {
+ if (_isDisposed) return;
+ _watchCue = WatchHapticCue(
+ id: const Uuid().v4(),
+ kind: 'failure',
+ issuedAt: DateTime.now(),
+ message: message,
+ );
+ _scheduleWatchSync(immediate: true);
+ }
+
+ void _handleWatchSnapshotDelivered(WatchSnapshot snapshot) {
+ final deliveredCue = snapshot.cue;
+ if (deliveredCue != null && _watchCue?.id == deliveredCue.id) {
+ // Delivery means future snapshots must stop carrying this event. The
+ // watch independently age-checks the retained application context, so a
+ // process restart cannot turn it back into a new failure.
+ _watchCue = null;
+ }
+ }
+
+ void _handleWatchDiagnosticsChanged() {
+ if (_watchBridge.diagnostics.value.paired) {
+ unawaited(_rememberWatchPairing());
+ }
+ }
+
+ Future _rememberWatchPairing() async {
+ if (_hasEverPairedWatch) return;
+
+ // Pairing history is intentionally one-way. An unpaired watch is the most
+ // important time to keep this diagnostic reachable, so no later native
+ // false is allowed to erase the evidence that the feature once existed.
+ _hasEverPairedWatch = true;
+ _notifyWatchDiagnosticVisibilityChanged();
+
+ final box = await _openBoxSafely(_preferencesBoxName);
+ if (box == null) return;
+ try {
+ await box.put('watch_has_ever_been_paired', true);
+ } catch (error) {
+ debugError('[WATCH] Failed to persist pairing history: $error');
+ }
+ }
+
+ void _notifyWatchDiagnosticVisibilityChanged() {
+ if (_isDisposed) return;
+ // The provider's normal notifier also schedules a watch snapshot. This
+ // flag only controls Settings visibility, so bypass that transport side
+ // effect and keep pairing persistence strictly observational.
+ super.notifyListeners();
+ }
+
+ ({
+ LiveActivityPhase phase,
+ String title,
+ String? detail,
+ DateTime? endsAt,
+ }) _resolveWatchPhase() {
+ final shared = _resolveLiveActivityPhase();
+ final watchPhase = resolveWatchSurfacePhase(
+ sharedPhase: shared.phase,
+ isSessionActive: _autoPingEnabled,
+ isSessionStarting: _autoPingStarting,
+ );
+ if (watchPhase == shared.phase) return shared;
+
+ return (
+ phase: LiveActivityPhase.idle,
+ title: 'Ready',
+ detail: 'No session running',
+ endsAt: null,
+ );
+ }
+
+ LiveActivitySnapshot? _buildLiveActivitySnapshot() {
+ final sessionId = _liveActivitySessionId;
+ if (!_liveActivitySessionActive || sessionId == null) {
+ return null;
+ }
+
+ final phase = _resolveLiveActivityPhase();
+ final repeaterState = _buildLiveActivityRepeaters();
+ final phaseDurationMs = _phaseDurationMsFor(phase.endsAt);
+ final pingColor = _resolveWatchPingColor();
+
+ return LiveActivitySnapshot(
+ sessionId: sessionId,
+ mode: _liveActivityModeTitle,
+ phase: phase.phase,
+ phaseTitle: phase.title,
+ phaseDetail: phase.detail,
+ phaseEndsAt: phase.endsAt,
+ phaseDurationMs: phaseDurationMs,
+ pingColor: pingColor,
+ isConnected: isConnected,
+ zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode,
+ txCount: _pingStats.txCount,
+ rxCount: _pingStats.rxCount,
+ discoveryCount: _pingStats.discCount,
+ traceCount: _pingStats.traceCount,
+ queueSize: _queueSize,
+ repeaters: repeaterState.repeaters,
+ totalHeardCount: repeaterState.totalCount,
+ repeatersAreCurrent: repeaterState.isCurrent,
+ updatedAt: DateTime.now(),
+ );
+ }
+
+ ({
+ LiveActivityPhase phase,
+ String title,
+ String? detail,
+ DateTime? endsAt,
+ }) _resolveLiveActivityPhase() {
+ if (_isInZoneGracePeriod) {
+ return (
+ phase: LiveActivityPhase.pausedOutsideZone,
+ title: 'Outside service area',
+ detail: 'Searching for a nearby wardriving zone',
+ endsAt: _zoneGraceEndsAt,
+ );
+ }
+
+ if (_isZoneTransferInProgress) {
+ return (
+ phase: LiveActivityPhase.pausedOutsideZone,
+ title: 'Changing region…',
+ detail: [_zoneTransferFrom, _zoneTransferTo]
+ .whereType()
+ .join(' → '),
+ endsAt: null,
+ );
+ }
+
+ if (_isAutoReconnecting || _connectionStep == ConnectionStep.reconnecting) {
+ return (
+ phase: LiveActivityPhase.disconnected,
+ title: 'Reconnecting…',
+ detail: 'Restoring MeshCore connection',
+ endsAt: null,
+ );
+ }
+
+ if (!isConnected) {
+ return (
+ phase: LiveActivityPhase.disconnected,
+ title: _connectionStep == ConnectionStep.disconnecting
+ ? 'Disconnecting…'
+ : 'Device disconnected',
+ detail: 'Open MeshMapper to reconnect',
+ endsAt: null,
+ );
+ }
+
+ if (isPendingDisable) {
+ return (
+ phase: LiveActivityPhase.stopping,
+ title: 'Stopping…',
+ detail: 'Finishing the current listening window',
+ endsAt: _rxWindowTimer.endTime ?? _discoveryWindowTimer.endTime,
+ );
+ }
+
+ if (_gpsStatus != GpsStatus.locked) {
+ return (
+ phase: LiveActivityPhase.waitingForGps,
+ title: 'Waiting for GPS',
+ detail: _liveActivityGpsLabel,
+ endsAt: null,
+ );
+ }
+
+ if ((_autoMode == AutoMode.active ||
+ _autoMode == AutoMode.hybrid ||
+ _autoMode == AutoMode.targeted) &&
+ !txAllowed) {
+ return (
+ phase: LiveActivityPhase.txBlocked,
+ title: 'TX unavailable',
+ detail: 'This zone is currently passive-only',
+ endsAt: null,
+ );
+ }
+
+ if (_liveActivityManualSession && _isPingSending) {
+ return (
+ phase: LiveActivityPhase.sending,
+ title: 'Sending ping…',
+ detail: null,
+ endsAt: null,
+ );
+ }
+
+ if (_discoveryWindowTimer.isRunning) {
+ final isTrace = _autoMode == AutoMode.targeted;
+ return (
+ phase: isTrace
+ ? LiveActivityPhase.listeningTrace
+ : LiveActivityPhase.listeningDiscovery,
+ title: isTrace ? 'Listening for trace…' : 'Listening…',
+ detail: isTrace ? _targetRepeaterDisplayName : 'Discovery responses',
+ endsAt: _discoveryWindowTimer.endTime,
+ );
+ }
+
+ if (_rxWindowTimer.isRunning) {
+ return (
+ phase: LiveActivityPhase.listening,
+ title: 'Listening…',
+ detail: 'Waiting for repeater echoes',
+ endsAt: _rxWindowTimer.endTime,
+ );
+ }
+
+ if (_liveActivityManualSession &&
+ _manualPingCooldownTimer.isRunning) {
+ return (
+ phase: LiveActivityPhase.cooldown,
+ title: 'Cooldown',
+ detail: 'Manual ping available when the timer ends',
+ endsAt: _manualPingCooldownTimer.endTime,
+ );
+ }
+
+ if (_autoPingTimer.isRunning) {
+ if (_autoPingTimer.skipReason != null) {
+ return (
+ phase: LiveActivityPhase.skipped,
+ title: 'Ping skipped',
+ detail: 'Move at least ${PingService.currentMinDistance} m',
+ endsAt: _autoPingTimer.endTime,
+ );
+ }
+
+ if (_autoMode == AutoMode.passive) {
+ return (
+ phase: LiveActivityPhase.waitingDiscovery,
+ title: 'Next discovery',
+ detail: null,
+ endsAt: _autoPingTimer.endTime,
+ );
+ }
+
+ if (_autoMode == AutoMode.targeted) {
+ return (
+ phase: LiveActivityPhase.waitingTrace,
+ title: 'Next trace',
+ detail: _targetRepeaterDisplayName,
+ endsAt: _autoPingTimer.endTime,
+ );
+ }
+
+ return (
+ phase: LiveActivityPhase.waiting,
+ title: 'Next ping',
+ detail: null,
+ endsAt: _autoPingTimer.endTime,
+ );
+ }
+
+ switch (_liveActivityOperation) {
+ case _LiveActivityOperation.sending:
+ return (
+ phase: LiveActivityPhase.sending,
+ title: 'Sending ping…',
+ detail: null,
+ endsAt: null,
+ );
+ case _LiveActivityOperation.discovering:
+ return (
+ phase: LiveActivityPhase.discovering,
+ title: 'Discovering…',
+ detail: 'Requesting nearby repeaters',
+ endsAt: null,
+ );
+ case _LiveActivityOperation.tracing:
+ return (
+ phase: LiveActivityPhase.tracing,
+ title: 'Tracing repeater…',
+ detail: _targetRepeaterDisplayName,
+ endsAt: null,
+ );
+ case null:
+ break;
+ }
+
+ if (_autoPingStarting || !_autoPingEnabled) {
+ return (
+ phase: LiveActivityPhase.starting,
+ title: 'Preparing session…',
+ detail: null,
+ endsAt: null,
+ );
+ }
+
+ return (
+ phase: LiveActivityPhase.active,
+ title: '$_liveActivityModeTitle active',
+ detail: 'Waiting for the next cycle',
+ endsAt: null,
+ );
+ }
+
+ ({
+ List repeaters,
+ int totalCount,
+ bool isCurrent,
+ }) _buildLiveActivityRepeaters() {
+ final cycleStartedAt = _liveActivityCycleStartedAt;
+ final topIsCurrent = cycleStartedAt != null &&
+ _liveActivityRepeatersUpdatedAt != null &&
+ !_liveActivityRepeatersUpdatedAt!.isBefore(cycleStartedAt);
+ final rxIsCurrent = cycleStartedAt != null &&
+ _liveActivityRxUpdatedAt != null &&
+ !_liveActivityRxUpdatedAt!.isBefore(cycleStartedAt);
+ final hasCurrent = topIsCurrent || rxIsCurrent;
+
+ final includeTop = !hasCurrent || topIsCurrent;
+ final includeRx = !hasCurrent || rxIsCurrent;
+ final repeatersById = {};
+
+ if (includeTop) {
+ for (final repeater in _liveActivityRepeaters) {
+ if (!repeater.snr.isFinite) continue;
+ final id = repeater.repeaterId.toUpperCase();
+ repeatersById[id] = LiveActivityRepeater(
+ id: id,
+ name: _resolveRepeaterDisplayName(id),
+ snr: repeater.snr,
+ typeColor: WatchGeoBuilder.overlayTypeColor(repeater.type),
+ snrColor: WatchGeoBuilder.snrColor(repeater.snr),
+ );
+ }
+ }
+
+ final rx = _rxOverlaySlot;
+ if (includeRx && rx != null && rx.snr.isFinite) {
+ final id = rx.repeaterId.toUpperCase();
+ final existing = repeatersById[id];
+ if (existing == null || rx.snr > existing.snr) {
+ repeatersById[id] = LiveActivityRepeater(
+ id: id,
+ name: _resolveRepeaterDisplayName(id),
+ snr: rx.snr,
+ typeColor: WatchGeoBuilder.overlayTypeColor(OverlayPingType.rx),
+ snrColor: WatchGeoBuilder.snrColor(rx.snr),
+ );
+ }
+ }
+
+ final repeaters = repeatersById.values.toList()
+ ..sort((a, b) => b.snr.compareTo(a.snr));
+
+ var totalCount = includeTop ? _liveActivityRepeaterTotalCount : 0;
+ if (includeRx &&
+ rx != null &&
+ rx.snr.isFinite &&
+ !_liveActivityRepeaters.any(
+ (entry) =>
+ entry.repeaterId.toUpperCase() == rx.repeaterId.toUpperCase(),
+ )) {
+ totalCount++;
+ }
+ if (totalCount < repeaters.length) totalCount = repeaters.length;
+
+ return (
+ repeaters: repeaters.take(3).toList(growable: false),
+ totalCount: totalCount,
+ isCurrent: hasCurrent,
+ );
+ }
+
+ String get _liveActivityModeTitle {
+ if (_liveActivityManualSession) return 'Manual';
+ return switch (_autoMode) {
+ AutoMode.active => 'Active',
+ AutoMode.passive => 'Passive',
+ AutoMode.hybrid => 'Hybrid',
+ AutoMode.targeted => 'Trace',
+ };
+ }
+
+ String get _liveActivityGpsLabel => switch (_gpsStatus) {
+ GpsStatus.permissionDenied => 'Location permission required',
+ GpsStatus.disabled => 'Location services disabled',
+ GpsStatus.searching => 'Searching for GPS signal',
+ GpsStatus.locked => 'GPS locked',
+ GpsStatus.outsideGeofence => 'Outside service area',
+ };
+
+ String? get _targetRepeaterDisplayName {
+ final id = _targetRepeaterId;
+ if (id == null || id.isEmpty) return null;
+ return _resolveRepeaterDisplayName(id) ?? id.toUpperCase();
+ }
+
+ String? _resolveRepeaterDisplayName(String rawId) {
+ final id = rawId.toUpperCase();
+ final exactMatches = _repeaters.where((repeater) {
+ return repeater.id.toUpperCase() == id ||
+ repeater.hexId.toUpperCase() == id ||
+ repeater
+ .displayHexId(overrideHopBytes: _hopBytes)
+ .toUpperCase() ==
+ id;
+ }).toList(growable: false);
+
+ if (exactMatches.length == 1) {
+ final name = exactMatches.single.name;
+ return name == 'Unknown' ? null : name;
+ }
+ if (exactMatches.isNotEmpty || id.length < 2) return null;
+
+ final prefixMatches = _repeaters.where((repeater) {
+ final hexId = repeater.hexId.toUpperCase();
+ return hexId.startsWith(id) || id.startsWith(hexId);
+ }).toList(growable: false);
+ if (prefixMatches.length != 1) return null;
+
+ final name = prefixMatches.single.name;
+ return name == 'Unknown' ? null : name;
+ }
+
// ============================================
// Initialization
// ============================================
@@ -1130,6 +2230,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_rxWindowTimer,
_discoveryWindowTimer,
]);
+ if (_liveActivityService.isSupportedPlatform) {
+ _timerListenable.addListener(_handleLiveActivityTimerChange);
+ }
+ if (_watchBridge.isSupportedPlatform) {
+ _watchBridge.diagnostics.addListener(_handleWatchDiagnosticsChanged);
+ _watchBridge.attachCommandHandler(
+ _handleWatchCommand,
+ onRefusal: _emitWatchFailure,
+ onSnapshotDelivered: _handleWatchSnapshotDelivered,
+ // Availability can become true long after provider startup when a
+ // watch is paired or its app is installed. Push the current state then
+ // rather than waiting for an unrelated phone-side notification.
+ onAvailabilityChanged: (available) {
+ if (available) _scheduleWatchSync(immediate: true);
+ },
+ );
+ }
// Initialize debug logging (enabled by default, respects user preference)
await _initDebugLogs();
@@ -1212,6 +2329,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
// Load user preferences
debugLog('[INIT] Loading preferences...');
await _loadPreferences();
+ await _loadWatchPairingPreference();
await _loadDeviceAntennaPreferences();
await _loadDevicePowerOverrides();
await _loadDeviceRealNames();
@@ -2513,6 +3631,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
() => handleSessionError('session_limit', null);
_pingService!.onTxPing = (ping) {
+ _markLiveActivityOperation(_LiveActivityOperation.sending);
_txPings.add(ping);
if (_txPings.length > _maxMapPins) _txPings.removeAt(0);
@@ -2614,13 +3733,23 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
'[APP] Updated TxLogEntry with ${existingEvents.length} direct, '
'${lastEntry.multiHopEvents.length} multi-hop events (real-time)');
- _updateTopRepeaters(
- existingEvents
- .where((e) => e.snr != null)
- .map((e) =>
- (repeaterId: e.repeaterId.toUpperCase(), snr: e.snr!))
- .toList(),
- OverlayPingType.tx);
+ final directRepeaters = existingEvents
+ .where((event) => event.snr != null)
+ .map((event) => (
+ repeaterId: event.repeaterId.toUpperCase(),
+ snr: event.snr!,
+ ))
+ .toList(growable: false);
+ _updateTopRepeaters(directRepeaters, OverlayPingType.tx);
+ _updateLiveActivityRepeaters([
+ ...directRepeaters,
+ ...lastEntry.multiHopEvents
+ .where((event) => event.snr != null)
+ .map((event) => (
+ repeaterId: event.repeaterId.toUpperCase(),
+ snr: event.snr!,
+ )),
+ ], OverlayPingType.tx);
debugLog('[APP] Calling notifyListeners() to update UI');
_notifyMapThrottled();
@@ -2675,6 +3804,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
multiHopEvents: multiHopEvents,
);
+ _updateLiveActivityRepeaters([
+ ...lastEntry.events
+ .where((event) => event.snr != null)
+ .map((event) => (
+ repeaterId: event.repeaterId.toUpperCase(),
+ snr: event.snr!,
+ )),
+ ...multiHopEvents
+ .where((event) => event.snr != null)
+ .map((event) => (
+ repeaterId: event.repeaterId.toUpperCase(),
+ snr: event.snr!,
+ )),
+ ], OverlayPingType.tx);
+
_notifyMapThrottled();
}
}
@@ -2683,6 +3827,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_pingService!.onPingProgressChanged = notifyListeners;
_pingService!.onAutoPingScheduled = (intervalMs, skipReason) {
+ _liveActivityOperation = null;
_autoPingTimer.startWithSkipReason(intervalMs, skipReason);
if (skipReason != null) {
@@ -2700,6 +3845,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
};
_pingService!.onDiscPing = (entry) {
+ _markLiveActivityOperation(_LiveActivityOperation.discovering);
_addDiscLogEntry(entry);
};
@@ -2710,20 +3856,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_audioService.playReceiveSound();
}
- _updateTopRepeaters(
- discPing.discoveredNodes
- .map((n) =>
- (repeaterId: n.repeaterId.toUpperCase(), snr: n.localSnr))
- .toList(),
- OverlayPingType.disc);
+ final heardRepeaters = discPing.discoveredNodes
+ .map((node) => (
+ repeaterId: node.repeaterId.toUpperCase(),
+ snr: node.localSnr,
+ ))
+ .toList(growable: false);
+ _updateTopRepeaters(heardRepeaters, OverlayPingType.disc);
+ _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc);
_notifyMapThrottled();
};
_pingService!.onTxWindowComplete = (directSuccess, multiHopEchoes) {
+ _liveActivityOperation = null;
double? lat;
double? lon;
List? allRepeaters;
+ final heardRepeaters = <({String repeaterId, double snr})>[];
if (_txLogEntries.isNotEmpty) {
final lastTx = _txLogEntries.last;
@@ -2750,7 +3900,21 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
if (directRepeaters.isNotEmpty || multiHopRepeaters.isNotEmpty) {
allRepeaters = [...directRepeaters, ...multiHopRepeaters];
}
- }
+
+ heardRepeaters.addAll(lastTx.events
+ .where((event) => event.snr?.isFinite ?? false)
+ .map((event) => (
+ repeaterId: event.repeaterId.toUpperCase(),
+ snr: event.snr!,
+ )));
+ heardRepeaters.addAll(multiHopEchoes
+ .where((event) => event.snr?.isFinite ?? false)
+ .map((event) => (
+ repeaterId: event.repeaterId.toUpperCase(),
+ snr: event.snr!,
+ )));
+ }
+ _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.tx);
final PingEventType eventType;
if (directSuccess) {
@@ -2770,9 +3934,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
};
_pingService!.onDiscoveryWindowComplete = (success) {
+ _liveActivityOperation = null;
double? lat;
double? lon;
List? repeaters;
+ final heardRepeaters = <({String repeaterId, double snr})>[];
if (_discLogEntries.isNotEmpty) {
final lastDisc = _discLogEntries.first;
@@ -2788,7 +3954,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
))
.toList();
}
+ heardRepeaters.addAll(lastDisc.discoveredNodes
+ .where((node) => node.localSnr.isFinite)
+ .map((node) => (
+ repeaterId: node.repeaterId.toUpperCase(),
+ snr: node.localSnr,
+ )));
}
+ _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc);
PingEventType eventType;
if (success) {
@@ -2808,10 +3981,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
};
_pingService!.onTracePing = (entry) {
+ _markLiveActivityOperation(_LiveActivityOperation.tracing);
_addTraceLogEntry(entry);
};
_pingService!.onTraceWindowComplete = (result) {
+ _liveActivityOperation = null;
double? lat;
double? lon;
List? repeaters;
@@ -2843,6 +4018,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
}
}
+ final traceSnr = result?.localSnr;
+ _updateLiveActivityRepeaters(
+ result != null && result.success && traceSnr != null
+ ? [(repeaterId: result.targetRepeaterId, snr: traceSnr)]
+ : const [],
+ OverlayPingType.trace,
+ );
+
recordPingEvent(
result != null && result.success
? PingEventType.traceSuccess
@@ -2881,6 +4064,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_autoPingEnabled = false;
_idleAutoStopReference = null;
+ _finishLiveActivitySession();
debugLog('[APP] Pending disable cleanup complete, cooldown running');
notifyListeners();
@@ -3433,6 +4617,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
}
Future _fullDisconnectCleanup() async {
+ _finishLiveActivitySession();
// Guard against double cleanup (e.g., reconnect timeout + BLE disconnect event)
if (_connectionStep == ConnectionStep.disconnected) {
debugLog('[CONN] Already disconnected, skipping duplicate cleanup');
@@ -3798,6 +4983,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
/// Disconnect from current device
Future disconnect() async {
+ _finishLiveActivitySession();
// Mark as user-requested so BLE disconnect listener doesn't trigger auto-reconnect
_userRequestedDisconnect = true;
@@ -4040,6 +5226,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_isPingSending = true;
notifyListeners();
+ var ownsLiveActivity = false;
+ var keepLiveActivity = false;
try {
// Check session validity before starting (skip in offline mode)
if (!_preferences.offlineMode) {
@@ -4051,8 +5239,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_startIdleDisconnectTimer();
debugLog('[PING] Sending manual TX ping');
- return await _pingService!.sendTxPing(manual: true);
+ ownsLiveActivity = !_liveActivitySessionActive;
+ if (ownsLiveActivity) {
+ _startLiveActivitySession(manual: true);
+ }
+ final sent = await _pingService!.sendTxPing(manual: true);
+ keepLiveActivity = sent;
+ return sent;
} finally {
+ if (ownsLiveActivity && !keepLiveActivity) {
+ _finishLiveActivitySession();
+ }
// Clear sending state on every path: session-check failure, exception,
// or success (RX window timer takes over showing the listening state)
_isPingSending = false;
@@ -4063,6 +5260,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
/// Check session validity before starting a wardrive action
/// Returns true if session is valid, false if expired (triggers disconnect)
Future _checkSessionBeforeAction() async {
+ _lastSessionCheckFailureReason = null;
final pos = _gpsService.lastPosition;
final result = await _apiService.checkSessionValid(
lat: pos?.latitude,
@@ -4070,6 +5268,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
);
if (!result.isValid) {
+ _lastSessionCheckFailureReason = _sessionCheckFailureMessage(
+ result.reason,
+ result.message,
+ );
debugWarn(
'[API] Session check failed: ${result.reason} - ${result.message ?? "Session expired"}');
// Note: onSessionError callback will trigger disconnect for critical errors
@@ -4078,6 +5280,19 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
return true;
}
+ String _sessionCheckFailureMessage(String? reason, String? message) {
+ // `zone_full` is the server-side form of the same TX prohibition already
+ // named "Passive Only" by watch controls. Reusing it avoids three wrist
+ // phrasings for one condition; other presentable server text stays intact.
+ if (reason == 'zone_full') return 'Passive Only';
+
+ final serverMessage = message?.trim();
+ if (serverMessage != null && serverMessage.isNotEmpty) {
+ return serverMessage;
+ }
+ return _getErrorMessage(reason, null);
+ }
+
/// Set the target repeater ID for targeted mode
void setTargetRepeaterId(String? id) {
_targetRepeaterId = id;
@@ -4153,6 +5368,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_autoPingEnabled = false;
_idleAutoStopReference = null;
+ _finishLiveActivitySession();
// Clear top-heard overlay on stop
_clearOverlayState();
@@ -4245,6 +5461,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
_rxLogger?.startWardriving();
_autoPingEnabled = true;
_idleAutoStopReference = DateTime.now();
+ _startLiveActivitySession();
// Start noise floor session for graph tracking
final sessionLabel = isPassive
@@ -4773,6 +5990,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
// 9. Update state
_autoPingEnabled = false;
_idleAutoStopReference = null;
+ _finishLiveActivitySession();
debugLog('[APP] Auto-ping mode stopped gracefully');
notifyListeners();
}
@@ -6143,6 +7361,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
/// Cancel all zone grace period timers.
void _cancelZoneGraceTimers() {
+ _zoneGraceEndsAt = null;
_zoneGraceTimer?.cancel();
_zoneGraceTimer = null;
_zoneGracePollingTimer?.cancel();
@@ -6198,7 +7417,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
// Keep alive: BLE, _meshCoreConnection, _pingService, _unifiedRxHandler,
// noise floor, and API session (backend auto-transfers on zone re-entry)
- // Start 5-minute countdown
+ // Start 5-minute countdown. Keep an absolute deadline so ActivityKit can
+ // render the timer without receiving an update every second.
+ _zoneGraceEndsAt = DateTime.now().add(_zoneGraceTimeout);
_zoneGraceSecondsRemaining = _zoneGraceTimeout.inSeconds;
// Overall timeout — abandon grace period after 5 minutes
@@ -7304,6 +8525,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
notifyListeners();
}
+ Future _loadWatchPairingPreference() async {
+ if (!_watchBridge.isSupportedPlatform) return;
+
+ final box = await _openBoxSafely(_preferencesBoxName);
+ if (box == null) return;
+ try {
+ // OR with the in-memory observation because WCSession may report a
+ // pairing while startup persistence is still loading. That race must
+ // never turn the one-way flag back off.
+ final wasPaired = box.get('watch_has_ever_been_paired') == true;
+ if (wasPaired && !_hasEverPairedWatch) {
+ _hasEverPairedWatch = true;
+ _notifyWatchDiagnosticVisibilityChanged();
+ }
+ } catch (error) {
+ debugError('[WATCH] Failed to load pairing history: $error');
+ }
+ }
+
/// Save user preferences to Hive storage
Future _savePreferences() async {
final box = await _openBoxSafely(_preferencesBoxName);
@@ -7799,12 +9039,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver {
@override
@override
void notifyListeners() {
- if (!_isDisposed) super.notifyListeners();
+ if (_isDisposed) return;
+ super.notifyListeners();
+ _scheduleLiveActivitySync();
}
@override
void dispose() {
_isDisposed = true;
+ _timerListenable.removeListener(_handleLiveActivityTimerChange);
+ _liveActivityService.dispose();
+ _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged);
+ _watchBridge.dispose();
WidgetsBinding.instance.removeObserver(this);
_adapterStateSubscription?.cancel();
_connectionSubscription?.cancel();
diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart
index d458128..93938f4 100644
--- a/lib/screens/settings_screen.dart
+++ b/lib/screens/settings_screen.dart
@@ -31,6 +31,7 @@ import '../widgets/upload_logs_dialog.dart';
import 'package:intl/intl.dart';
import '../widgets/app_toast.dart';
import 'offline_maps_screen.dart';
+import 'watch_diagnostics_screen.dart';
/// Settings screen for user preferences and API configuration
class SettingsScreen extends StatefulWidget {
@@ -1160,6 +1161,25 @@ class _SettingsScreenState extends State {
}),
],
]),
+
+ // Pairing history is one-way: after an unpair this remains at the
+ // bottom of Settings because that failure is when it is most useful.
+ if (appState.shouldShowWatchDiagnostics)
+ _buildSection(context, 'Apple Watch', [
+ ListTile(
+ leading: const Icon(Icons.watch_outlined),
+ title: const Text('Watch Connectivity'),
+ subtitle: const Text('Inspect pairing and delivery state'),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) =>
+ WatchDiagnosticsScreen(bridge: appState.watchBridge),
+ ),
+ ),
+ ),
+ ]),
],
),
);
diff --git a/lib/screens/watch_diagnostics_screen.dart b/lib/screens/watch_diagnostics_screen.dart
new file mode 100644
index 0000000..dbd4129
--- /dev/null
+++ b/lib/screens/watch_diagnostics_screen.dart
@@ -0,0 +1,193 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+
+import '../services/watch/watch_bridge_service.dart';
+
+/// On-device evidence for why the phone is or is not sending to Apple Watch.
+///
+/// The bridge remains the single source of truth so opening this route cannot
+/// accidentally create a second connectivity policy that drifts from the send
+/// gate it is meant to explain.
+class WatchDiagnosticsScreen extends StatefulWidget {
+ const WatchDiagnosticsScreen({
+ super.key,
+ required this.bridge,
+ });
+
+ final WatchBridgeService bridge;
+
+ @override
+ State createState() => _WatchDiagnosticsScreenState();
+}
+
+class _WatchDiagnosticsScreenState extends State {
+ Timer? _clockTimer;
+ var _secondsSinceRefresh = 0;
+ var _refreshing = false;
+
+ @override
+ void initState() {
+ super.initState();
+ unawaited(_refresh());
+ _clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
+ if (!mounted) return;
+ setState(() => _secondsSinceRefresh++);
+
+ // WCSession status properties are local reads. Poll only while this
+ // diagnostic is visible so reachability stays live without adding work
+ // to snapshot scheduling or turning normal app use into a hot path.
+ if (_secondsSinceRefresh >= 5) {
+ _secondsSinceRefresh = 0;
+ unawaited(widget.bridge.refreshAvailability());
+ }
+ });
+ }
+
+ @override
+ void dispose() {
+ _clockTimer?.cancel();
+ super.dispose();
+ }
+
+ Future _refresh() async {
+ if (_refreshing) return;
+ setState(() => _refreshing = true);
+ try {
+ await widget.bridge.refreshAvailability();
+ } finally {
+ if (mounted) setState(() => _refreshing = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ toolbarHeight: 40,
+ title: const Text('Watch Connectivity', style: TextStyle(fontSize: 18)),
+ actions: [
+ TextButton.icon(
+ onPressed: _refreshing ? null : _refresh,
+ icon: _refreshing
+ ? const SizedBox.square(
+ dimension: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Icon(Icons.refresh),
+ label: const Text('Refresh'),
+ ),
+ ],
+ ),
+ body: ValueListenableBuilder(
+ valueListenable: widget.bridge.diagnostics,
+ builder: (context, status, _) {
+ final failing = status.failingSyncConditions;
+ final gateDetail = status.canSync
+ ? 'activated, paired, and installed are all true'
+ : failing.isEmpty
+ ? 'The iOS platform gate is unavailable'
+ : 'Failing ${failing.length == 1 ? 'condition' : 'conditions'}: '
+ '${failing.join(', ')}';
+
+ return ListView(
+ padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
+ children: [
+ _buildSection(context, 'Native WCSession', [
+ _statusTile('supported', status.supported),
+ _statusTile('paired', status.paired),
+ _statusTile('installed', status.installed),
+ _statusTile('reachable', status.reachable),
+ _statusTile('activated', status.activated),
+ ]),
+ _buildSection(context, 'Dart Bridge', [
+ _statusTile('canSync', status.canSync),
+ ListTile(
+ leading: Icon(
+ status.canSync ? Icons.check_circle : Icons.error_outline,
+ color: status.canSync ? Colors.green : Colors.orange,
+ ),
+ title: const Text('Sync gate'),
+ subtitle: Text(gateDetail),
+ ),
+ ]),
+ _buildSection(context, 'Delivery History', [
+ ListTile(
+ leading: const Icon(Icons.send_outlined),
+ title: const Text('Last successful send'),
+ subtitle: Text(_timeSince(status.lastSuccessfulSendAt)),
+ ),
+ ListTile(
+ leading: const Icon(Icons.sync_alt),
+ title: const Text('Last availability change'),
+ subtitle: Text(_timeSince(status.lastAvailabilityChangedAt)),
+ ),
+ ListTile(
+ leading: const Icon(Icons.fact_check_outlined),
+ title: const Text('Last send outcome'),
+ subtitle: Text(switch (status.lastSendDelivered) {
+ true => 'Delivered (delivered == true)',
+ false => 'Refused (delivered != true)',
+ null => 'No send attempted this run',
+ }),
+ ),
+ ]),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _statusTile(String name, bool value) {
+ return ListTile(
+ leading: Icon(
+ value ? Icons.check_circle_outline : Icons.cancel_outlined,
+ color: value ? Colors.green : Colors.orange,
+ ),
+ title: Text(name),
+ trailing: Text(value ? 'true' : 'false'),
+ );
+ }
+
+ String _timeSince(DateTime? timestamp) {
+ if (timestamp == null) return 'Not recorded this run';
+ final elapsed = DateTime.now().difference(timestamp);
+ if (elapsed.inSeconds < 5) return 'Just now';
+ if (elapsed.inMinutes < 1) return '${elapsed.inSeconds}s ago';
+ if (elapsed.inHours < 1) return '${elapsed.inMinutes}m ago';
+ if (elapsed.inDays < 1) {
+ return '${elapsed.inHours}h ${elapsed.inMinutes.remainder(60)}m ago';
+ }
+ return '${elapsed.inDays}d ${elapsed.inHours.remainder(24)}h ago';
+ }
+
+ Widget _buildSection(
+ BuildContext context, String title, List children) {
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Card(
+ margin: EdgeInsets.zero,
+ clipBehavior: Clip.antiAlias,
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
+ child: Text(
+ title,
+ style: Theme.of(context).textTheme.titleSmall?.copyWith(
+ color: Theme.of(context).colorScheme.primary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ...children,
+ const SizedBox(height: 4),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart
index c7e61de..cdac81b 100644
--- a/lib/services/countdown_timer_service.dart
+++ b/lib/services/countdown_timer_service.dart
@@ -14,8 +14,17 @@ import '../utils/debug_logger_io.dart';
class CountdownTimerService extends ChangeNotifier {
Timer? _timer;
DateTime? _endTime;
+
+ /// Absolute deadline used by system surfaces such as iOS Live Activities.
+ DateTime? get endTime => _endTime;
int? _durationMs;
+ /// Total length of the current countdown.
+ ///
+ /// Paired with [endTime] this lets a remote surface — the watch — draw a
+ /// progress bar locally from absolute values, with no per-second updates.
+ int? get durationMs => _durationMs;
+
/// Check if timer is running
bool get isRunning => _timer != null;
diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart
new file mode 100644
index 0000000..95a897d
--- /dev/null
+++ b/lib/services/live_activity/live_activity_models.dart
@@ -0,0 +1,180 @@
+import '../watch/watch_color.dart';
+
+/// High-level phase shared by native glance surfaces.
+///
+/// [idle] is watch-only: the Live Activity builder still uses its session-only
+/// resolver directly, while the always-present watch projects that resolver's
+/// no-session fallback to this value.
+enum LiveActivityPhase {
+ idle,
+ active,
+ starting,
+ sending,
+ discovering,
+ tracing,
+ listening,
+ listeningDiscovery,
+ listeningTrace,
+ waiting,
+ waitingDiscovery,
+ waitingTrace,
+ cooldown,
+ skipped,
+ stopping,
+ waitingForGps,
+ pausedOutsideZone,
+ disconnected,
+ txBlocked,
+}
+
+extension LiveActivityPhaseWireValue on LiveActivityPhase {
+ String get wireValue => switch (this) {
+ LiveActivityPhase.idle => 'idle',
+ LiveActivityPhase.active => 'active',
+ LiveActivityPhase.starting => 'starting',
+ LiveActivityPhase.sending => 'sending',
+ LiveActivityPhase.discovering => 'discovering',
+ LiveActivityPhase.tracing => 'tracing',
+ LiveActivityPhase.listening => 'listening',
+ LiveActivityPhase.listeningDiscovery => 'listening_discovery',
+ LiveActivityPhase.listeningTrace => 'listening_trace',
+ LiveActivityPhase.waiting => 'waiting',
+ LiveActivityPhase.waitingDiscovery => 'waiting_discovery',
+ LiveActivityPhase.waitingTrace => 'waiting_trace',
+ LiveActivityPhase.cooldown => 'cooldown',
+ LiveActivityPhase.skipped => 'skipped',
+ LiveActivityPhase.stopping => 'stopping',
+ LiveActivityPhase.waitingForGps => 'waiting_for_gps',
+ LiveActivityPhase.pausedOutsideZone => 'paused_outside_zone',
+ LiveActivityPhase.disconnected => 'disconnected',
+ LiveActivityPhase.txBlocked => 'tx_blocked',
+ };
+}
+
+/// Compact repeater observation included in a Live Activity update.
+class LiveActivityRepeater {
+ const LiveActivityRepeater({
+ required this.id,
+ required this.snr,
+ this.name,
+ this.typeColor,
+ this.snrColor,
+ });
+
+ final String id;
+ final String? name;
+ final double snr;
+ final WatchColor? typeColor;
+ final WatchColor? snrColor;
+
+ Map toMap() => {
+ 'id': id,
+ 'name': name,
+ 'snr': snr.isFinite ? snr : 0.0,
+ if (typeColor != null) 'typeColor': typeColor!.toMap(),
+ if (snrColor != null) 'snrColor': snrColor!.toMap(),
+ };
+}
+
+/// Complete, serializable snapshot rendered by ActivityKit.
+class LiveActivitySnapshot {
+ const LiveActivitySnapshot({
+ required this.sessionId,
+ required this.mode,
+ required this.phase,
+ required this.phaseTitle,
+ required this.isConnected,
+ required this.txCount,
+ required this.rxCount,
+ required this.discoveryCount,
+ required this.traceCount,
+ required this.queueSize,
+ required this.repeaters,
+ required this.totalHeardCount,
+ required this.repeatersAreCurrent,
+ required this.updatedAt,
+ this.phaseDetail,
+ this.phaseEndsAt,
+ this.phaseDurationMs,
+ this.pingColor,
+ this.zoneCode,
+ });
+
+ final String sessionId;
+ final String mode;
+ final LiveActivityPhase phase;
+ final String phaseTitle;
+ final String? phaseDetail;
+ final DateTime? phaseEndsAt;
+ final int? phaseDurationMs;
+ final WatchColor? pingColor;
+ final bool isConnected;
+ final String? zoneCode;
+ final int txCount;
+ final int rxCount;
+ final int discoveryCount;
+ final int traceCount;
+ final int queueSize;
+ final List repeaters;
+ final int totalHeardCount;
+ final bool repeatersAreCurrent;
+ final DateTime updatedAt;
+
+ Map toMap() => {
+ 'sessionId': sessionId,
+ 'mode': mode,
+ 'phase': phase.wireValue,
+ 'phaseTitle': phaseTitle,
+ 'phaseDetail': phaseDetail,
+ 'phaseEndsAt': phaseEndsAt?.millisecondsSinceEpoch,
+ if (phaseDurationMs != null) 'phaseDurationMs': phaseDurationMs,
+ if (pingColor != null) 'pingColor': pingColor!.toMap(),
+ 'isConnected': isConnected,
+ 'zoneCode': zoneCode,
+ 'txCount': txCount,
+ 'rxCount': rxCount,
+ 'discoveryCount': discoveryCount,
+ 'traceCount': traceCount,
+ 'queueSize': queueSize,
+ 'repeaters': repeaters.map((repeater) => repeater.toMap()).toList(),
+ 'totalHeardCount': totalHeardCount,
+ 'repeatersAreCurrent': repeatersAreCurrent,
+ 'updatedAt': updatedAt.millisecondsSinceEpoch,
+ };
+
+ /// Fields that must bypass the normal update throttle.
+ String get urgencyKey => [
+ sessionId,
+ mode,
+ phase.wireValue,
+ phaseTitle,
+ phaseDetail ?? '',
+ // Seconds, not milliseconds. **At millisecond resolution any
+ // recomputation of the deadline reads as news** and forces an immediate
+ // send past the 15 s non-urgent throttle, because the key differs even
+ // when the phase has not changed.
+ //
+ // Measured on the 2026-08-16 walk, from the phone's powerlog
+ // (`PLApplicationAgent_EventPoint_LiveActivityUpdates`): 720 updates in
+ // 90 minutes, one every 7.5 s, against Apple Fitness's 2 in the same
+ // window. 69 % of the gaps were under the throttle, and 116 of them
+ // were **under one second** — which no real phase change can produce,
+ // and which the 200 ms debounce should already have absorbed.
+ //
+ // A deadline that moves by milliseconds is not something a wearer can
+ // see: the widget renders it with `Text(timerInterval:)` and
+ // `ProgressView(timerInterval:)`, both of which show whole seconds. So
+ // rounding here cannot lose anything the surface could display, while a
+ // genuine phase change moves the deadline by seconds at minimum and
+ // still bypasses the throttle exactly as intended.
+ phaseEndsAt == null
+ ? 0
+ : (phaseEndsAt!.millisecondsSinceEpoch / 1000).round(),
+ phaseDurationMs ?? 0,
+ pingColor?.r ?? '',
+ pingColor?.g ?? '',
+ pingColor?.b ?? '',
+ isConnected,
+ zoneCode ?? '',
+ ].join('|');
+}
diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart
new file mode 100644
index 0000000..f41dc6f
--- /dev/null
+++ b/lib/services/live_activity/live_activity_service.dart
@@ -0,0 +1,203 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+import '../../utils/debug_logger_io.dart';
+import 'live_activity_models.dart';
+
+typedef LiveActivitySnapshotBuilder = LiveActivitySnapshot? Function();
+
+/// Owns the Flutter-to-ActivityKit bridge and coalesces noisy app-state changes.
+///
+/// Timer ticks are represented by absolute phase deadlines, allowing SwiftUI to
+/// render the countdown locally without an ActivityKit update every second.
+class LiveActivityService {
+ LiveActivityService({
+ @visibleForTesting MethodChannel? channel,
+ @visibleForTesting
+ Duration unavailableRetryDelay = const Duration(seconds: 30),
+ @visibleForTesting
+ Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval,
+ }) : _channel = channel ?? const MethodChannel('meshmapper/live_activity'),
+ _unavailableRetryDelay = unavailableRetryDelay,
+ _minimumNonUrgentInterval = minimumNonUrgentInterval;
+
+ static const Duration _debounceDelay = Duration(milliseconds: 200);
+
+ /// Floor between two updates that carry no change the wearer is waiting on.
+ ///
+ /// Counters, queue depth and the heard-repeater rows churn constantly in a
+ /// busy session, and none of them is worth an ActivityKit round trip the
+ /// moment it moves. Everything in [LiveActivitySnapshot.urgencyKey] — a phase
+ /// change, a ping outcome, connection loss, leaving the zone — bypasses this
+ /// entirely, so what it throttles is the noise and not the news.
+ ///
+ /// **This is a saving on both devices.** A locally generated ActivityKit
+ /// update is synchronised to a paired Apple Watch for the Smart Stack and
+ /// counts against *its* Live Activity budget, so the phone's update rate is
+ /// also a wrist battery cost — on top of the snapshots the native watch app
+ /// receives over WatchConnectivity, which are a separate channel entirely.
+ ///
+ /// Fifteen seconds takes the ceiling from 30 updates a minute to 4. The real
+ /// reduction is smaller, because the payload fingerprint already suppresses
+ /// updates that change nothing.
+ static const Duration defaultMinimumNonUrgentInterval = Duration(seconds: 15);
+
+ final MethodChannel _channel;
+ final Duration _unavailableRetryDelay;
+ final Duration _minimumNonUrgentInterval;
+
+ Timer? _scheduledUpdate;
+ LiveActivitySnapshotBuilder? _pendingSnapshotBuilder;
+ String? _lastPayload;
+ String? _lastUrgencyKey;
+ DateTime? _lastSentAt;
+ String? _unavailableSessionId;
+ DateTime? _unavailableRetryAt;
+ bool _disposed = false;
+ bool _didReconcileNativeState = false;
+ Future _operationChain = Future.value();
+
+ bool get isSupportedPlatform =>
+ !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
+
+ void schedule(
+ LiveActivitySnapshotBuilder snapshotBuilder, {
+ bool immediate = false,
+ }) {
+ if (_disposed || !isSupportedPlatform) return;
+
+ _pendingSnapshotBuilder = snapshotBuilder;
+ _scheduledUpdate?.cancel();
+
+ if (immediate) {
+ _enqueueFlush();
+ return;
+ }
+
+ _scheduledUpdate = Timer(_debounceDelay, _enqueueFlush);
+ }
+
+ void _enqueueFlush() {
+ _operationChain = _operationChain.then((_) => _flush()).catchError(
+ (Object error) {
+ debugError('[LIVE ACTIVITY] Update queue failed: $error');
+ },
+ );
+ }
+
+ Future _flush() async {
+ _scheduledUpdate?.cancel();
+ _scheduledUpdate = null;
+
+ if (_disposed || !isSupportedPlatform) return;
+
+ final snapshot = _pendingSnapshotBuilder?.call();
+ if (snapshot == null) {
+ if (_lastPayload == null && _didReconcileNativeState) return;
+ await _endCurrentActivity(immediate: _lastPayload == null);
+ return;
+ }
+
+ if (_unavailableSessionId == snapshot.sessionId) {
+ final retryAt = _unavailableRetryAt;
+ final now = DateTime.now();
+ if (retryAt != null && now.isBefore(retryAt)) {
+ // Authorization can be enabled in Settings while this session is
+ // running. One scheduled retry makes that recover without asking
+ // ActivityKit on every high-frequency provider notification.
+ _scheduledUpdate = Timer(retryAt.difference(now), _enqueueFlush);
+ return;
+ }
+ _unavailableSessionId = null;
+ _unavailableRetryAt = null;
+ } else if (_unavailableSessionId != null) {
+ _unavailableSessionId = null;
+ _unavailableRetryAt = null;
+ }
+
+ final payload = snapshot.toMap();
+ // updatedAt is metadata for ActivityKit's stale date, not a visible state
+ // change. Excluding it from the fingerprint prevents 500 ms countdown
+ // timer ticks from causing native updates; SwiftUI renders countdowns from
+ // the absolute phaseEndsAt deadline instead.
+ final fingerprintPayload = Map.from(payload)
+ ..remove('updatedAt');
+ final encoded = jsonEncode(fingerprintPayload);
+ if (encoded == _lastPayload) return;
+
+ final urgent = snapshot.urgencyKey != _lastUrgencyKey;
+ final lastSentAt = _lastSentAt;
+ if (!urgent && lastSentAt != null) {
+ final elapsed = DateTime.now().difference(lastSentAt);
+ if (elapsed < _minimumNonUrgentInterval) {
+ _scheduledUpdate = Timer(
+ _minimumNonUrgentInterval - elapsed,
+ _enqueueFlush,
+ );
+ return;
+ }
+ }
+
+ try {
+ final result = await _channel.invokeMethod