From 10599111da7f220d71f9dc5887e451e314822109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rkan?= Date: Wed, 15 Jul 2026 01:29:04 +0200 Subject: [PATCH 01/71] feat(ios): add Live Activity support for mapping sessions Add an ActivityKit and WidgetKit integration for iOS mapping sessions. - show the current wardriving phase, including sending, listening, cooldown, and waiting states - render system-driven countdowns from shared phase deadlines - display up to three heard repeaters with SNR and hop information - include session counters, queue state, zone, GPS, and connection status - provide layouts for the Lock Screen, Dynamic Island, and compact CarPlay presentation - throttle native updates and avoid duplicate Live Activities - mark stale session data and show a final summary when a session ends - keep the integration dependency-free and isolated from existing map presentation logic --- docs/LIVE_ACTIVITIES.md | 29 + ios/Flutter/LiveActivity.xcconfig | 1 + ios/MeshMapperLiveActivity/Info.plist | 29 + .../MeshMapperLiveActivity.swift | 497 +++++++++++++++ .../MeshMapperLiveActivityBundle.swift | 9 + ios/Runner.xcodeproj/project.pbxproj | 203 ++++++ ios/Runner/AppDelegate.swift | 16 + ios/Runner/Info.plist | 2 + ios/Runner/LiveActivityManager.swift | 258 ++++++++ ios/Shared/MeshMapperActivityAttributes.swift | 39 ++ lib/providers/app_state_provider.dart | 585 +++++++++++++++++- lib/services/countdown_timer_service.dart | 3 + .../live_activity/live_activity_models.dart | 136 ++++ .../live_activity/live_activity_service.dart | 154 +++++ .../live_activity_models_test.dart | 102 +++ 15 files changed, 2046 insertions(+), 17 deletions(-) create mode 100644 docs/LIVE_ACTIVITIES.md create mode 100644 ios/Flutter/LiveActivity.xcconfig create mode 100644 ios/MeshMapperLiveActivity/Info.plist create mode 100644 ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift create mode 100644 ios/MeshMapperLiveActivity/MeshMapperLiveActivityBundle.swift create mode 100644 ios/Runner/LiveActivityManager.swift create mode 100644 ios/Shared/MeshMapperActivityAttributes.swift create mode 100644 lib/services/live_activity/live_activity_models.dart create mode 100644 lib/services/live_activity/live_activity_service.dart create mode 100644 test/services/live_activity/live_activity_models_test.dart 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..efe9fa7 --- /dev/null +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -0,0 +1,497 @@ +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) { + MeshMapperBestSignal(state: context.state) + } + DynamicIslandExpandedRegion(.center) { + MeshMapperPhaseLabel(state: context.state) + } + 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: { + 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) + } + .keylineTint(context.state.phaseColor) + } + .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 { + MeshMapperSmallActivityView(context: context) + } else { + MeshMapperLockScreenView(context: context) + } + } +} + +private struct MeshMapperLockScreenView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 8) { + MeshMapperModeBadge(mode: context.state.mode) + Spacer(minLength: 8) + MeshMapperStatusLabel(context: context) + } + + HStack(alignment: .center, spacing: 9) { + Image(systemName: context.state.phaseSymbol) + .font(.title3.weight(.semibold)) + .foregroundStyle(context.state.phaseColor) + .frame(width: 24) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 1) { + Text(context.state.phaseTitle) + .font(.headline.weight(.semibold)) + .lineLimit(1) + if let detail = context.state.phaseDetail, !detail.isEmpty { + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 6) + MeshMapperCountdown( + state: context.state, + font: .headline.monospacedDigit().weight(.semibold) + ) + } + + HStack(alignment: .bottom, spacing: 12) { + MeshMapperRepeaterSummary(state: context.state) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .trailing, spacing: 4) { + HStack(spacing: 9) { + MeshMapperMetric( + label: context.state.primaryMetricLabel, + value: context.state.primaryMetricValue + ) + MeshMapperMetric(label: "RX", value: context.state.rxCount) + } + if context.state.queueSize > 0 { + Label("Queue \(context.state.queueSize)", systemImage: "arrow.triangle.2.circlepath") + .font(.caption2.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .foregroundStyle(.white) + } +} + +@available(iOSApplicationExtension 18.0, *) +private struct MeshMapperSmallActivityView: View { + let context: ActivityViewContext + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 7) { + Image(systemName: context.state.phaseSymbol) + .foregroundStyle(context.state.phaseColor) + .accessibilityHidden(true) + Text(context.state.mode.uppercased()) + .font(.caption2.weight(.bold)) + .tracking(0.6) + Spacer(minLength: 4) + if context.isStale { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + .foregroundStyle(.orange) + .accessibilityLabel("Update delayed") + } else if let zone = context.state.zoneCode { + Text(zone) + .font(.system(.caption2, design: .monospaced).weight(.semibold)) + .foregroundStyle(.secondary) + } + } + + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(context.state.phaseTitle) + .font(.headline.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.8) + Spacer(minLength: 4) + MeshMapperCountdown( + state: context.state, + font: .headline.monospacedDigit().weight(.semibold) + ) + } + + MeshMapperBestRepeaterRow(state: context.state) + + HStack(spacing: 10) { + Text("\(context.state.primaryMetricLabel) \(context.state.primaryMetricValue)") + Text("RX \(context.state.rxCount)") + Spacer(minLength: 0) + if context.state.queueSize > 0 { + Label("\(context.state.queueSize)", systemImage: "arrow.triangle.2.circlepath") + } + } + .font(.caption2.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + } + .padding(12) + .foregroundStyle(.white) + } +} + +private struct MeshMapperStatusLabel: View { + let context: ActivityViewContext + + var body: some View { + Label( + context.isStale + ? "Update delayed" : context.state.zoneCode ?? context.state.connectionLabel, + systemImage: context.isStale + ? "exclamationmark.triangle.fill" + : context.state.isConnected + ? "antenna.radiowaves.left.and.right" + : "wifi.slash" + ) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .foregroundStyle( + context.isStale || !context.state.isConnected + ? Color.orange : MeshMapperPalette.secondary + ) + } +} + +private struct MeshMapperRepeaterSummary: View { + let state: MeshMapperActivityAttributes.ContentState + + 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) + .foregroundStyle(.secondary) + if state.totalHeardCount > 0 { + Text("\(state.totalHeardCount)") + .font(.caption2.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + } + } + + if state.repeaters.isEmpty { + Text(state.repeaterEmptyLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + ForEach(state.repeaters.prefix(2)) { repeater in + HStack(spacing: 6) { + Circle() + .fill(MeshMapperPalette.secondary) + .frame(width: 5, height: 5) + .accessibilityHidden(true) + Text(repeater.displayName) + .font(.caption.weight(.medium)) + .lineLimit(1) + Text(repeater.snr.formattedSnr) + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(repeater.displayName), SNR \(repeater.snr.formattedSnr)") + } + } + } + } +} + +private struct MeshMapperBestRepeaterRow: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + if let best = state.repeaters.first { + HStack(spacing: 6) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.caption2) + .foregroundStyle(MeshMapperPalette.secondary) + .accessibilityHidden(true) + Text(best.displayName) + .font(.caption.weight(.medium)) + .lineLimit(1) + Spacer(minLength: 4) + Text(best.snr.formattedSnr) + .font(.caption.monospacedDigit().weight(.semibold)) + if state.totalHeardCount > 1 { + Text("+\(state.totalHeardCount - 1)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel( + "Best repeater \(best.displayName), SNR \(best.snr.formattedSnr), " + + "\(state.totalHeardCount) heard" + ) + } else { + Text(state.repeaterEmptyLabel) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } +} + +private struct MeshMapperIslandBottom: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + VStack(spacing: 6) { + MeshMapperBestRepeaterRow(state: state) + HStack(spacing: 12) { + Text("\(state.primaryMetricLabel) \(state.primaryMetricValue)") + Text("RX \(state.rxCount)") + if let zone = state.zoneCode { + Spacer() + Text(zone) + } + } + .font(.caption.monospacedDigit().weight(.medium)) + .foregroundStyle(.secondary) + } + } +} + +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) + .background(MeshMapperPalette.primary, in: Capsule()) + } +} + +private struct MeshMapperPhaseLabel: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + VStack(spacing: 2) { + Text(state.phaseTitle) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + MeshMapperCountdown( + state: state, + font: .caption.monospacedDigit().weight(.semibold) + ) + .foregroundStyle(.secondary) + } + } +} + +private struct MeshMapperBestSignal: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + if let best = state.repeaters.first { + VStack(alignment: .trailing, spacing: 2) { + Text(best.snr.formattedSnr) + .font(.subheadline.monospacedDigit().weight(.semibold)) + Text("best SNR") + .font(.caption2) + .foregroundStyle(.secondary) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") + } else { + Image(systemName: "waveform.slash") + .foregroundStyle(.secondary) + .accessibilityLabel("No repeaters heard") + } + } +} + +private struct MeshMapperCompactTrailing: View { + let state: MeshMapperActivityAttributes.ContentState + + var body: some View { + if state.hasActiveCountdown { + MeshMapperCountdown( + state: state, + font: .caption2.monospacedDigit().weight(.bold) + ) + .frame(minWidth: 28) + } else if let best = state.repeaters.first { + Text(best.snr.formattedSnr) + .font(.caption2.monospacedDigit().weight(.bold)) + .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") + } else { + Text("\(state.rxCount)") + .font(.caption2.monospacedDigit().weight(.bold)) + .accessibilityLabel("\(state.rxCount) received") + } + } +} + +private struct MeshMapperCountdown: View { + let state: MeshMapperActivityAttributes.ContentState + let font: Font + + var body: some View { + if let end = state.phaseEndsAt, end > Date() { + Text(timerInterval: Date()...end, countsDown: true, showsHours: false) + .font(font) + .lineLimit(1) + .accessibilityLabel("Time remaining") + } + } +} + +private struct MeshMapperMetric: View { + let label: String + let value: Int + + var body: some View { + Text("\(label) \(value)") + .font(.caption.monospacedDigit().weight(.semibold)) + .foregroundStyle(.secondary) + .accessibilityLabel("\(label) \(value)") + } +} + +private enum MeshMapperPalette { + static let background = Color(red: 0.055, green: 0.075, blue: 0.105) + static let primary = Color(red: 0.12, green: 0.43, blue: 0.92) + static let secondary = Color(red: 0.30, green: 0.82, blue: 0.78) +} + +extension MeshMapperActivityAttributes.ContentState { + fileprivate var hasActiveCountdown: 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 "No repeaters heard yet" + default: + return "No repeaters 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" + } + } + + fileprivate var phaseColor: Color { + switch phase { + case "sending", "discovering", "tracing": return MeshMapperPalette.primary + case "listening", "listening_discovery", "listening_trace": return MeshMapperPalette.secondary + 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 Double { + fileprivate var formattedSnr: String { + let sign = self >= 0 ? "+" : "" + return "\(sign)\(formatted(.number.precision(.fractionLength(1)))) dB" + } +} 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/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index d045a55..bcf8474 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,12 @@ 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, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -27,6 +33,13 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + A60000000000000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = A50000000000000000000001; + remoteInfo = MeshMapperLiveActivityExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -40,6 +53,17 @@ 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 */ @@ -67,6 +91,13 @@ 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 = ""; }; + 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -87,6 +118,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000002 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -128,6 +166,7 @@ 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, + A20000000000000000000007 /* LiveActivity.xcconfig */, ); name = Flutter; sourceTree = ""; @@ -137,6 +176,8 @@ children = ( 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + A30000000000000000000001 /* Shared */, + A30000000000000000000002 /* MeshMapperLiveActivity */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 88C2A374596BDB4F1DE4A0B4 /* Pods */, @@ -149,6 +190,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */, ); name = Products; sourceTree = ""; @@ -163,11 +205,30 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + A20000000000000000000001 /* LiveActivityManager.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; + A30000000000000000000001 /* Shared */ = { + isa = PBXGroup; + children = ( + A20000000000000000000002 /* MeshMapperActivityAttributes.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 */ @@ -198,6 +259,7 @@ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( E42ACB57393E36F474C65ADB /* [CP] Check Pods Manifest.lock */, + A40000000000000000000004 /* Embed Foundation Extensions */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -210,12 +272,30 @@ buildRules = ( ); dependencies = ( + A70000000000000000000001 /* PBXTargetDependency */, ); name = Runner; 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 */ @@ -237,6 +317,9 @@ CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; }; + A50000000000000000000001 = { + CreatedOnToolsVersion = 15.1; + }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; @@ -254,6 +337,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + A50000000000000000000001 /* MeshMapperLiveActivityExtension */, ); }; /* End PBXProject section */ @@ -277,6 +361,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A40000000000000000000003 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -405,10 +496,22 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + A10000000000000000000001 /* LiveActivityManager.swift in Sources */, + A10000000000000000000002 /* MeshMapperActivityAttributes.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m 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; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -417,6 +520,11 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + A70000000000000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A50000000000000000000001 /* MeshMapperLiveActivityExtension */; + targetProxy = A60000000000000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -728,6 +836,91 @@ }; 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 = DQC6TNKG5P; + 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 = net.meshmapper.app.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + 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 = DQC6TNKG5P; + 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 = net.meshmapper.app.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_COMPILATION_MODE = wholemodule; + }; + 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 = DQC6TNKG5P; + 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 = net.meshmapper.app.liveactivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -761,6 +954,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A90000000000000000000001 /* Build configuration list for PBXNativeTarget "MeshMapperLiveActivityExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A80000000000000000000001 /* Debug */, + A80000000000000000000002 /* Release */, + A80000000000000000000003 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 34f4799..f12d4c3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -67,6 +67,7 @@ class IOSMapOfflineBridge { @main @objc class AppDelegate: FlutterAppDelegate { private let mapOfflineBridge = IOSMapOfflineBridge() + private let liveActivityManager = LiveActivityManager() override func application( _ application: UIApplication, @@ -108,6 +109,21 @@ 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: 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 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSSupportsLiveActivities + diff --git a/ios/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift new file mode 100644 index 0000000..a806a08 --- /dev/null +++ b/ios/Runner/LiveActivityManager.swift @@ -0,0 +1,258 @@ +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) + ) + } + + 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"]), + 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.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 + } +} diff --git a/ios/Shared/MeshMapperActivityAttributes.swift b/ios/Shared/MeshMapperActivityAttributes.swift new file mode 100644 index 0000000..25f16d2 --- /dev/null +++ b/ios/Shared/MeshMapperActivityAttributes.swift @@ -0,0 +1,39 @@ +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 { + struct HeardRepeater: Codable, Hashable, Identifiable { + let id: String + let name: String? + let snr: Double + + var displayName: String { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? id : trimmed + } + } + + struct ContentState: Codable, Hashable { + var mode: String + var phase: String + var phaseTitle: String + var phaseDetail: String? + var phaseEndsAt: Date? + 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/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 059dda5..3f884f1 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -45,6 +45,8 @@ 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/custom_api_service.dart'; import '../utils/constants.dart'; import '../utils/geo_validation.dart'; @@ -70,6 +72,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 +128,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final DiscoveryWindowTimer _discoveryWindowTimer; // Discovery listening window (Passive Mode) late final Listenable _timerListenable; + + final LiveActivityService _liveActivityService = LiveActivityService(); + bool _liveActivitySessionActive = false; + bool _liveActivityManualSession = false; + String? _liveActivitySessionId; + DateTime? _liveActivityCycleStartedAt; + _LiveActivityOperation? _liveActivityOperation; MeshCoreConnection? _meshCoreConnection; PingService? _pingService; UnifiedRxHandler? _unifiedRxHandler; @@ -222,6 +233,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ({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})> _liveActivityRepeaters = []; + int _liveActivityRepeaterTotalCount = 0; + DateTime? _liveActivityRepeatersUpdatedAt; + DateTime? _liveActivityRxUpdatedAt; + // Targeted mode state String? _targetRepeaterId; @@ -349,6 +367,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); @@ -598,15 +617,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _topRepeatersOverlay = fresh.take(3).toList(); } + void _updateLiveActivityRepeaters( + Iterable<({String repeaterId, double snr})> current) { + 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)) + .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). void _updateRxOverlaySlot(String repeaterId, double snr) { final entry = (repeaterId: repeaterId.toUpperCase(), snr: snr); 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 @@ -620,6 +662,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxOverlaySlot = null; _rxOverlayWindowTimer?.cancel(); _rxOverlayWindowTimer = null; + _liveActivityRepeaters = []; + _liveActivityRepeaterTotalCount = 0; + _liveActivityRepeatersUpdatedAt = null; + _liveActivityRxUpdatedAt = null; } List get txLogEntries => List.unmodifiable(_txLogEntries); @@ -1072,6 +1118,420 @@ 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 || !_liveActivityService.isSupportedPlatform) return; + _liveActivityService.schedule( + _buildLiveActivitySnapshot, + immediate: immediate, + ); + } + + LiveActivitySnapshot? _buildLiveActivitySnapshot() { + final sessionId = _liveActivitySessionId; + if (!_liveActivitySessionActive || sessionId == null) { + return null; + } + + final phase = _resolveLiveActivityPhase(); + final repeaterState = _buildLiveActivityRepeaters(); + + return LiveActivitySnapshot( + sessionId: sessionId, + mode: _liveActivityModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + 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, + ); + } + } + + 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, + ); + } + } + + 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 +1590,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _rxWindowTimer, _discoveryWindowTimer, ]); + if (_liveActivityService.isSupportedPlatform) { + _timerListenable.addListener(_handleLiveActivityTimerChange); + } // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -2513,6 +2976,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 +3078,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!, + )), + ]); debugLog('[APP] Calling notifyListeners() to update UI'); _notifyMapThrottled(); @@ -2675,6 +3149,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!, + )), + ]); + _notifyMapThrottled(); } } @@ -2683,6 +3172,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 +3190,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { }; _pingService!.onDiscPing = (entry) { + _markLiveActivityOperation(_LiveActivityOperation.discovering); _addDiscLogEntry(entry); }; @@ -2710,20 +3201,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); _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 +3245,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); final PingEventType eventType; if (directSuccess) { @@ -2770,9 +3279,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 +3299,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); PingEventType eventType; if (success) { @@ -2808,10 +3326,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 +3363,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + final traceSnr = result?.localSnr; + _updateLiveActivityRepeaters( + result != null && result.success && traceSnr != null + ? [(repeaterId: result.targetRepeaterId, snr: traceSnr)] + : const [], + ); + recordPingEvent( result != null && result.success ? PingEventType.traceSuccess @@ -2881,6 +3408,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingEnabled = false; _idleAutoStopReference = null; + _finishLiveActivitySession(); debugLog('[APP] Pending disable cleanup complete, cooldown running'); notifyListeners(); @@ -3433,6 +3961,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 +4327,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 +4570,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 +4583,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; @@ -4153,6 +4694,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _autoPingEnabled = false; _idleAutoStopReference = null; + _finishLiveActivitySession(); // Clear top-heard overlay on stop _clearOverlayState(); @@ -4245,6 +4787,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 +5316,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 +6687,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 +6743,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 @@ -7799,12 +8346,16 @@ 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(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); _connectionSubscription?.cancel(); diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index c7e61de..ccb6d58 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -14,6 +14,9 @@ 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; /// Check if timer is running 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..ec93f64 --- /dev/null +++ b/lib/services/live_activity/live_activity_models.dart @@ -0,0 +1,136 @@ +/// High-level phase shown by the iOS Live Activity. +enum LiveActivityPhase { + 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.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, + }); + + final String id; + final String? name; + final double snr; + + Map toMap() => { + 'id': id, + 'name': name, + 'snr': snr.isFinite ? snr : 0.0, + }; +} + +/// 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.zoneCode, + }); + + final String sessionId; + final String mode; + final LiveActivityPhase phase; + final String phaseTitle; + final String? phaseDetail; + final DateTime? phaseEndsAt; + 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, + '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 ?? '', + phaseEndsAt?.millisecondsSinceEpoch ?? 0, + 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..bf5cb27 --- /dev/null +++ b/lib/services/live_activity/live_activity_service.dart @@ -0,0 +1,154 @@ +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 { + static const MethodChannel _channel = + MethodChannel('meshmapper/live_activity'); + static const Duration _debounceDelay = Duration(milliseconds: 200); + static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + + Timer? _scheduledUpdate; + LiveActivitySnapshotBuilder? _pendingSnapshotBuilder; + String? _lastPayload; + String? _lastUrgencyKey; + DateTime? _lastSentAt; + String? _unavailableSessionId; + 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) return; + if (_unavailableSessionId != null) { + _unavailableSessionId = 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('sync', payload); + _didReconcileNativeState = true; + if (result == false) { + _unavailableSessionId = snapshot.sessionId; + debugLog('[LIVE ACTIVITY] Live Activities are unavailable or disabled'); + return; + } + _lastPayload = encoded; + _lastUrgencyKey = snapshot.urgencyKey; + _lastSentAt = DateTime.now(); + } on MissingPluginException { + // Expected on non-iOS test hosts and older generated iOS projects. + } on PlatformException catch (error) { + debugError( + '[LIVE ACTIVITY] ActivityKit sync failed: ' + '${error.code}: ${error.message}', + ); + } catch (error) { + debugError('[LIVE ACTIVITY] Unexpected sync failure: $error'); + } + } + + Future _endCurrentActivity({required bool immediate}) async { + try { + await _channel.invokeMethod( + 'end', + {'immediate': immediate}, + ); + } on MissingPluginException { + // Expected on non-iOS test hosts. + } on PlatformException catch (error) { + debugError( + '[LIVE ACTIVITY] ActivityKit end failed: ' + '${error.code}: ${error.message}', + ); + } catch (error) { + debugError('[LIVE ACTIVITY] Unexpected end failure: $error'); + } finally { + _didReconcileNativeState = true; + _lastPayload = null; + _lastUrgencyKey = null; + _lastSentAt = null; + _unavailableSessionId = null; + } + } + + void dispose() { + _disposed = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _pendingSnapshotBuilder = null; + } +} diff --git a/test/services/live_activity/live_activity_models_test.dart b/test/services/live_activity/live_activity_models_test.dart new file mode 100644 index 0000000..cb186de --- /dev/null +++ b/test/services/live_activity/live_activity_models_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; + +void main() { + test('serializes the compact ActivityKit snapshot contract', () { + final updatedAt = DateTime.utc(2026, 7, 14, 18); + final phaseEndsAt = updatedAt.add(const Duration(seconds: 7)); + final snapshot = LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Hybrid', + phase: LiveActivityPhase.listening, + phaseTitle: 'Listening…', + phaseDetail: 'Waiting for repeater echoes', + phaseEndsAt: phaseEndsAt, + isConnected: true, + zoneCode: 'JKG', + txCount: 42, + rxCount: 318, + discoveryCount: 8, + traceCount: 0, + queueSize: 2, + repeaters: const [ + LiveActivityRepeater( + id: 'A6', + name: 'Huskvarna', + snr: 12.4, + ), + ], + totalHeardCount: 3, + repeatersAreCurrent: true, + updatedAt: updatedAt, + ); + + expect(snapshot.toMap(), { + 'sessionId': 'session-1', + 'mode': 'Hybrid', + 'phase': 'listening', + 'phaseTitle': 'Listening…', + 'phaseDetail': 'Waiting for repeater echoes', + 'phaseEndsAt': phaseEndsAt.millisecondsSinceEpoch, + 'isConnected': true, + 'zoneCode': 'JKG', + 'txCount': 42, + 'rxCount': 318, + 'discoveryCount': 8, + 'traceCount': 0, + 'queueSize': 2, + 'repeaters': [ + { + 'id': 'A6', + 'name': 'Huskvarna', + 'snr': 12.4, + }, + ], + 'totalHeardCount': 3, + 'repeatersAreCurrent': true, + 'updatedAt': updatedAt.millisecondsSinceEpoch, + }); + }); + + test('urgency key changes for phase deadlines but not counters', () { + final now = DateTime.utc(2026, 7, 14, 18); + + LiveActivitySnapshot make({ + int rxCount = 1, + DateTime? phaseEndsAt, + }) { + return LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Active', + phase: LiveActivityPhase.waiting, + phaseTitle: 'Next ping', + phaseEndsAt: phaseEndsAt ?? now.add(const Duration(seconds: 30)), + isConnected: true, + txCount: 1, + rxCount: rxCount, + discoveryCount: 0, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: false, + updatedAt: now, + ); + } + + expect(make(rxCount: 1).urgencyKey, make(rxCount: 2).urgencyKey); + expect( + make().urgencyKey, + isNot(make(phaseEndsAt: now.add(const Duration(seconds: 15))).urgencyKey), + ); + }); + + test('normalizes non-finite repeater values before encoding', () { + const repeater = LiveActivityRepeater( + id: 'A6', + snr: double.nan, + ); + + expect(repeater.toMap()['snr'], 0.0); + }); +} From 2708c82e97ae78483ddba8a93dcee01f3eabbf78 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 16:39:06 -0700 Subject: [PATCH 02/71] Fix unnecessary string interpolation braces in Live Activity title The merged Live Activity work introduced the repo's only analyzer issue (unnecessary_brace_in_string_interps). `dev` analyzes clean, so this restores that baseline on the watch-app branch. --- lib/providers/app_state_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 3f884f1..b52b3ed 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1410,7 +1410,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return ( phase: LiveActivityPhase.active, - title: '${_liveActivityModeTitle} active', + title: '$_liveActivityModeTitle active', detail: 'Waiting for the next cycle', endsAt: null, ); From 2547c52fed5be7de7d58c2084cf6c7871d50b6af Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 16:53:06 -0700 Subject: [PATCH 03/71] Add MeshMapperWatch watchOS app target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Apple Watch companion: the target skeleton only. The watch is a mirror-and-remote for a session the iPhone owns, so this ships no session logic — WatchConnectivity, map, node list, and controls follow in later phases. Single-target watchOS app (WKApplication), watchOS 26.0, embedded into Runner via an Embed Watch Content phase so `flutter build ipa` carries it along. Also routes bundle IDs and signing team through MESHMAPPER_BUNDLE_PREFIX and MESHMAPPER_DEVELOPMENT_TEAM, defined once at project level. Both resolve to the previous literals, so nothing changes by default. This exists because a watch app's bundle ID must be prefixed by its companion's: testing on a Personal Team means moving every ID together, which is now one field instead of four targets. Verified: builds for simulator, embeds at Runner.app/Watch/MeshMapperWatch.app with all variables resolved, installs and launches on a paired iPhone 17 Pro / Apple Watch Series 11 simulator pair. --- .../AccentColor.colorset/Contents.json | 20 ++ .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + ios/MeshMapperWatch/ContentView.swift | 23 ++ ios/MeshMapperWatch/Info.plist | 30 ++ ios/MeshMapperWatch/MeshMapperWatchApp.swift | 18 ++ ios/Runner.xcodeproj/project.pbxproj | 273 ++++++++++++++++-- 7 files changed, 351 insertions(+), 32 deletions(-) create mode 100644 ios/MeshMapperWatch/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/MeshMapperWatch/Assets.xcassets/Contents.json create mode 100644 ios/MeshMapperWatch/ContentView.swift create mode 100644 ios/MeshMapperWatch/Info.plist create mode 100644 ios/MeshMapperWatch/MeshMapperWatchApp.swift 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..49c81cd --- /dev/null +++ b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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..b123f1e --- /dev/null +++ b/ios/MeshMapperWatch/ContentView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Placeholder root view. +/// +/// Phase 1's only job is proving the target builds, embeds in Runner, and +/// launches on the watch. Phase 3 replaces this with the map. +struct ContentView: View { + var body: some View { + VStack(spacing: 4) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.title2) + Text("MeshMapper") + .font(.headline) + Text("Waiting for iPhone") + .font(.caption2) + .foregroundStyle(.secondary) + } + } +} + +#Preview { + ContentView() +} diff --git a/ios/MeshMapperWatch/Info.plist b/ios/MeshMapperWatch/Info.plist new file mode 100644 index 0000000..e481d35 --- /dev/null +++ b/ios/MeshMapperWatch/Info.plist @@ -0,0 +1,30 @@ + + + + + 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) + WKRunsIndependentlyOfCompanionApp + + + diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift new file mode 100644 index 0000000..b2b5959 --- /dev/null +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -0,0 +1,18 @@ +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. +/// +/// Phase 1 ships the target skeleton only — the WatchConnectivity client, map, +/// node list, and controls arrive in later phases. +@main +struct MeshMapperWatchApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index bcf8474..5b0d014 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,21 +8,26 @@ /* Begin PBXBuildFile section */ 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, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AD2A4CC4FD28C0416F14D0 /* ContentView.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 */; }; 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, ); }; }; + CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */; }; + E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87786D0E1C88A11BAB16DA95 /* MeshMapperWatchApp.swift */; }; + E83718073D76FB741949ED05 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6CC8647C002484845F02D0CE /* Assets.xcassets */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -33,6 +38,13 @@ 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 */; @@ -43,6 +55,17 @@ /* 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; @@ -67,18 +90,25 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 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 = ""; }; 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 = ""; }; 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 = ""; }; + 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 = ""; }; + 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 = ""; }; 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; }; + 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -86,11 +116,6 @@ 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 = ""; }; - 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 = ""; }; 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 = ""; }; @@ -98,9 +123,21 @@ 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; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 27F55F79605B24B31D20B683 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 507BD8685B0F59BD768C20E3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -133,10 +170,31 @@ 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 */, + ); + name = MeshMapperWatch; + path = MeshMapperWatch; + sourceTree = ""; + }; + 321ECD9614BDCAFCF545D162 /* watchOS */ = { + isa = PBXGroup; + children = ( + 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */, + ); + name = watchOS; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -182,6 +240,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, 88C2A374596BDB4F1DE4A0B4 /* Pods */, 1E85EC0983B84C8E2376ABCD /* Frameworks */, + 2CEC7BCDA4A562621033D8AD /* MeshMapperWatch */, ); sourceTree = ""; }; @@ -191,6 +250,7 @@ 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, A20000000000000000000006 /* MeshMapperLiveActivityExtension.appex */, + 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */, ); name = Products; sourceTree = ""; @@ -232,6 +292,23 @@ /* 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" */; @@ -252,14 +329,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 */, @@ -273,8 +348,12 @@ ); dependencies = ( A70000000000000000000001 /* PBXTargetDependency */, + F203972D6336527AC5F4F89D /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -300,9 +379,6 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -331,6 +407,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -338,6 +417,7 @@ 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, A50000000000000000000001 /* MeshMapperLiveActivityExtension */, + 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */, ); }; /* End PBXProject section */ @@ -350,6 +430,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4039BA5EEB0F88665D8EAFE2 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E83718073D76FB741949ED05 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -512,6 +600,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FBC0EF55C50DCA90E416F7D1 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */, + 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -525,6 +622,12 @@ target = A50000000000000000000001 /* MeshMapperLiveActivityExtension */; targetProxy = A60000000000000000000001 /* PBXContainerItemProxy */; }; + F203972D6336527AC5F4F89D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = MeshMapperWatch; + target = 26F4F6F7B0F55BC605124558 /* MeshMapperWatch */; + targetProxy = 964501C97A62900A93BB2E74 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -580,7 +683,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; @@ -593,6 +696,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; @@ -615,7 +720,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; @@ -633,7 +738,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"; @@ -652,7 +757,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"; @@ -669,13 +774,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 = 26.0; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -709,7 +843,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; @@ -728,6 +862,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; @@ -769,7 +905,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; @@ -782,6 +918,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; @@ -806,7 +944,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"; @@ -828,7 +966,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; @@ -836,6 +974,35 @@ }; 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 = 26.0; + }; + name = Release; + }; A80000000000000000000001 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = A20000000000000000000007 /* LiveActivity.xcconfig */; @@ -844,7 +1011,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.2; @@ -854,14 +1021,14 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + PRODUCT_BUNDLE_IDENTIFIER = "$(MESHMAPPER_BUNDLE_PREFIX).liveactivity"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -873,7 +1040,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.2; @@ -883,13 +1050,13 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + 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"; - SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; }; @@ -901,7 +1068,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = DQC6TNKG5P; + DEVELOPMENT_TEAM = "$(MESHMAPPER_DEVELOPMENT_TEAM)"; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = MeshMapperLiveActivity/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 16.2; @@ -911,16 +1078,46 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; - PRODUCT_BUNDLE_IDENTIFIER = net.meshmapper.app.liveactivity; + 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"; - SWIFT_COMPILATION_MODE = wholemodule; }; 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 = 26.0; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -964,13 +1161,25 @@ 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; From a9fff83ef17bc65f7b669dcdcf213dbf17078991 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 17:30:13 -0700 Subject: [PATCH 04/71] Add watch transport: snapshots down, commands up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Apple Watch companion. The wire is now real in both directions; the wrist UI is still a raw debug dump, replaced by the map in Phase 3. Shared contract in ios/Shared/MeshMapperWatchPayload.swift is compiled into both Runner and the watch target so it cannot drift, with the Dart mirror in lib/services/watch/. WatchSnapshot composes LiveActivitySnapshot rather than re-deriving phase and counter semantics, so both surfaces always agree. Three decisions worth keeping: - Countdowns ship as absolute deadlines, never ticks. The watch renders them with Text(timerInterval:), so a session sends about one update per phase transition instead of one per second. - Colours resolve on the phone. Dart owns the colour-vision palettes, so accessibility palettes work on the wrist with no duplicated code. - The watch sends intents, never state. Every guard is re-evaluated in _handleWatchCommand, so a stale payload cannot cause a transmit. Unlike the Live Activity, the watch receives snapshots even with no session running — otherwise the start button could never be reached from the wrist. Two bugs found by testing rather than review: - WatchSessionManager held its FlutterMethodChannel weakly. setMethodCallHandler makes the messenger retain the handler block, not the channel, so a channel left in an AppDelegate local deallocates and every inbound command was silently dropped. The other channels survive as locals because they only receive; this is the first that invokes Dart from native. - The watch pre-checked isReachable before sending. That flag lags reality — the simulator reported unreachable while still delivering messages seconds later — which turned a stale flag into a refused tap. errorHandler is now the source of truth. Verified on paired iPhone 17 Pro / Apple Watch Series 11 simulators: phone state renders on the wrist (phase, counters, GPS fix, disabled controls with reason), and requestSnapshot round-trips watch → Swift → Dart → ack. 138 tests pass, 27 of them new. --- ios/MeshMapperWatch/ContentView.swift | 160 ++++++++- ios/MeshMapperWatch/MeshMapperWatchApp.swift | 8 +- ios/MeshMapperWatch/WatchSessionClient.swift | 159 +++++++++ ios/Runner.xcodeproj/project.pbxproj | 14 + ios/Runner/AppDelegate.swift | 17 + ios/Runner/WatchSessionManager.swift | 225 ++++++++++++ ios/Shared/MeshMapperWatchPayload.swift | 208 ++++++++++++ lib/providers/app_state_provider.dart | 207 ++++++++++- lib/services/watch/watch_bridge_service.dart | 210 ++++++++++++ lib/services/watch/watch_geo_builder.dart | 213 ++++++++++++ lib/services/watch/watch_models.dart | 320 ++++++++++++++++++ .../watch/watch_geo_builder_test.dart | 279 +++++++++++++++ .../watch/watch_wire_contract_test.dart | 244 +++++++++++++ 13 files changed, 2249 insertions(+), 15 deletions(-) create mode 100644 ios/MeshMapperWatch/WatchSessionClient.swift create mode 100644 ios/Runner/WatchSessionManager.swift create mode 100644 ios/Shared/MeshMapperWatchPayload.swift create mode 100644 lib/services/watch/watch_bridge_service.dart create mode 100644 lib/services/watch/watch_geo_builder.dart create mode 100644 lib/services/watch/watch_models.dart create mode 100644 test/services/watch/watch_geo_builder_test.dart create mode 100644 test/services/watch/watch_wire_contract_test.dart diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index b123f1e..aed783b 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -1,23 +1,159 @@ import SwiftUI -/// Placeholder root view. +/// Phase 2 debug dump. /// -/// Phase 1's only job is proving the target builds, embeds in Runner, and -/// launches on the watch. Phase 3 replaces this with the map. +/// Deliberately ugly: this exists to prove the transport carries real state +/// and that commands round-trip with an ack. Phase 3 replaces it with the map. struct ContentView: View { + @Environment(WatchSessionClient.self) private var client + var body: some View { - VStack(spacing: 4) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.title2) - Text("MeshMapper") - .font(.headline) - Text("Waiting for iPhone") + 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) + // The whole dump dims when the phone has gone quiet, so stale data is + // never mistaken for live data. + .opacity(client.isStale ? 0.45 : 1.0) + } + } + + 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(red: color.r, green: color.g, blue: color.b)) + .frame(width: 8, height: 8) + } + Text(s.mode).font(.caption2) + // Absolute deadline rendered locally — no per-second traffic. + 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) { + if let c = node.snrColor { + Circle() + .fill(Color(red: c.r, green: c.g, blue: c.b)) + .frame(width: 5, height: 5) + } + Text(node.name).font(.system(size: 10)).lineLimit(1) + Spacer() + if let snr = node.snr { + Text(String(format: "%.1f", snr)) + .font(.system(size: 10).monospacedDigit()) + } + } + } } } -} -#Preview { - ContentView() + 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") { + client.send(sessionActive ? .stopSession : .startSession) + } + .disabled(!(client.snapshot?.controls.canStartStop ?? false)) + + Button("Manual ping") { + client.send(.manualPing) + } + .disabled(!(client.snapshot?.controls.canManualPing ?? false)) + + Button("Refresh") { + client.send(.requestSnapshot) + } + } + .font(.caption2) + .buttonStyle(.bordered) + } + + private var sessionActive: Bool { + client.snapshot?.controls.isSessionActive ?? false + } } diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index b2b5959..9fbce36 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -6,13 +6,17 @@ import SwiftUI /// 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. /// -/// Phase 1 ships the target skeleton only — the WatchConnectivity client, map, -/// node list, and controls arrive in later phases. +/// Phase 2 ships the transport and a raw debug dump; the map, node list, and +/// real controls arrive in later phases. @main struct MeshMapperWatchApp: App { + @State private var client = WatchSessionClient() + var body: some Scene { WindowGroup { ContentView() + .environment(client) + .onAppear { client.refresh() } } } } diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift new file mode 100644 index 0000000..82a7a9f --- /dev/null +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -0,0 +1,159 @@ +import Foundation +import SwiftUI +import WatchConnectivity + +/// 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. +@Observable +final class WatchSessionClient: NSObject { + /// 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? + + /// Set when the phone refuses a command, so the wrist can say why. + private(set) var lastRefusal: String? + + /// Set when a payload arrives from a wire version this build predates. + private(set) var versionMismatch = false + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + var isReachable: Bool { session?.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 + + var isStale: Bool { + guard let receivedAt else { return true } + return Date().timeIntervalSince(receivedAt) > Self.staleAfter + } + + /// 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() { + guard let session else { return } + session.delegate = self + + if session.activationState == .activated { + ingest(context: session.receivedApplicationContext) + send(.requestSnapshot, silent: true) + return + } + + pendingRefresh = true + session.activate() + } + + 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. + /// Sends without pre-checking `isReachable`. + /// + /// That flag lags reality — during testing the simulator reported + /// unreachable while messages were still being delivered a second or two + /// later. Gating on it turns a stale flag into a refused tap, so the send is + /// attempted unconditionally and `errorHandler` is the source of truth. + func send(_ kind: WatchCommand.Kind, silent: Bool = false) { + guard let session, session.activationState == .activated else { + if !silent { lastRefusal = "Not connected to iPhone" } + return + } + + let command = WatchCommand(kind: kind, id: UUID().uuidString) + guard let data = try? MeshMapperWatchWire.encoder.encode(command), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + if !silent { lastRefusal = "Could not encode command" } + return + } + + session.sendMessage( + [MeshMapperWatchWire.commandKey: dict], + replyHandler: { [weak self] reply in + NSLog("[WATCH] reply for \(kind.rawValue): \(reply)") + Task { @MainActor in + let accepted = reply["accepted"] as? Bool ?? false + if accepted { + self?.lastRefusal = nil + } else if !silent { + self?.lastRefusal = reply["reason"] as? String ?? "Refused" + } + } + }, + errorHandler: { [weak self] error in + NSLog("[WATCH] sendMessage(\(kind.rawValue)) failed: \(error.localizedDescription)") + Task { @MainActor in + if !silent { self?.lastRefusal = error.localizedDescription } + } + } + ) + } + + // MARK: - Ingest + + private func ingest(context: [String: Any]) { + guard let data = context[MeshMapperWatchWire.payloadKey] as? Data else { return } + ingest(data: data) + } + + private 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 in self.versionMismatch = true } + return + } + + Task { @MainActor in + self.versionMismatch = false + self.snapshot = decoded + self.receivedAt = Date() + } + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionClient: WCSessionDelegate { + func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + if activationState == .activated { + ingest(context: session.receivedApplicationContext) + if pendingRefresh { + pendingRefresh = false + send(.requestSnapshot, silent: true) + } + } + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + ingest(context: applicationContext) + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + guard let data = message[MeshMapperWatchWire.payloadKey] as? Data else { return } + ingest(data: data) + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5b0d014..e0ec83f 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 15EB8B11C186344E7D096C70 /* MeshMapperWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 74331FACF5FD72D49FF952AD /* MeshMapperWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4FB810C0D8676DD8AB0B1B30 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */; }; 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AD2A4CC4FD28C0416F14D0 /* ContentView.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 */; }; @@ -25,9 +26,12 @@ 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 */; }; CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */; }; 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 */ @@ -93,11 +97,14 @@ 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 = ""; }; 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 = ""; }; + 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 = ""; }; @@ -182,6 +189,7 @@ 64AD2A4CC4FD28C0416F14D0 /* ContentView.swift */, 111B35B32FAB66FA2E78E0BE /* Info.plist */, 6CC8647C002484845F02D0CE /* Assets.xcassets */, + 178C77B846A00943CD881203 /* WatchSessionClient.swift */, ); name = MeshMapperWatch; path = MeshMapperWatch; @@ -267,6 +275,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, A20000000000000000000001 /* LiveActivityManager.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 4DB902C46F9528E4D932613C /* WatchSessionManager.swift */, ); path = Runner; sourceTree = ""; @@ -275,6 +284,7 @@ isa = PBXGroup; children = ( A20000000000000000000002 /* MeshMapperActivityAttributes.swift */, + 68B2C4905F4EEE82DDA8825A /* MeshMapperWatchPayload.swift */, ); path = Shared; sourceTree = ""; @@ -587,6 +597,8 @@ 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; }; @@ -606,6 +618,8 @@ files = ( E6EDFA2E3EBEDBAFD72A5B9F /* MeshMapperWatchApp.swift in Sources */, 6AF9D4D984EEF729333DD5B5 /* ContentView.swift in Sources */, + B69988072090B261D65915C7 /* MeshMapperWatchPayload.swift in Sources */, + A40A14B14EA7033DDEF33B80 /* WatchSessionClient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index f12d4c3..3f7e7a1 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -68,6 +68,7 @@ class IOSMapOfflineBridge { @objc class AppDelegate: FlutterAppDelegate { private let mapOfflineBridge = IOSMapOfflineBridge() private let liveActivityManager = LiveActivityManager() + private let watchSessionManager = WatchSessionManager() override func application( _ application: UIApplication, @@ -124,6 +125,22 @@ class IOSMapOfflineBridge { 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/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift new file mode 100644 index 0000000..f50cf5e --- /dev/null +++ b/ios/Runner/WatchSessionManager.swift @@ -0,0 +1,225 @@ +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] + } + return [ + "supported": true, + "paired": session.isPaired, + "installed": session.isWatchAppInstalled, + "reachable": session.isReachable, + "activated": session.activationState == .activated, + ] + } + + 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 the ack. Dart owns the decision; + /// this side never evaluates whether a transmit is legal. + 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)") + } + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + + /// Reactivate after a watch switch, otherwise the session stays dead. + func sessionDidDeactivate(_ session: WCSession) { + session.activate() + } + + func sessionWatchStateDidChange(_ session: WCSession) { + // A newly installed or newly paired watch has no context yet. + lastContextData = nil + } + + 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) + } + + 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/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift new file mode 100644 index 0000000..850b3cf --- /dev/null +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -0,0 +1,208 @@ +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. + static let version = 1 + + /// Caps, mirrored in Dart. Enforced on send *and* validated on receive. + static let maxPings = 60 + static let maxRepeaters = 20 + static let maxHeard = 7 +} + +// 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 + let name: String + let lat: Double + let lon: Double + let color: WatchColor + let heardThisCycle: Bool +} + +/// A row in the "recently responded" panel. +struct WatchHeardNode: Codable, Hashable, Identifiable { + let id: String + let name: String + let snr: Double? + let rssi: Int? + /// nil = direct echo; otherwise the number of hops. + let hops: Int? + let seenCount: Int + let atMs: Double + let distanceM: Double? + /// SNR traffic-light colour, resolved by Dart. + let snrColor: 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 + /// 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? +} + +// 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 +} + +// 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? + 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? + + let geo: WatchGeo + let controls: WatchControls + let cue: WatchHapticCue? + let updatedAtMs: Double + + /// 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) } + } +} + +// 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 + /// Client-generated, so the phone can dedupe redelivered commands. + let id: String +} + +/// The phone's answer to a command. +struct WatchCommandAck: Codable, Hashable { + let id: String + let accepted: Bool + /// Why it was refused, for display on the wrist. + let reason: String? +} + +// 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 b52b3ed..d08e8a7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -47,6 +47,9 @@ 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'; @@ -130,6 +133,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { late final Listenable _timerListenable; final LiveActivityService _liveActivityService = LiveActivityService(); + final WatchBridgeService _watchBridge = WatchBridgeService(); + + /// Last position sent to the watch, held until the fix moves far enough to + /// be worth an update. See [_resolveWatchPosition]. + WatchPosition? _lastWatchPosition; bool _liveActivitySessionActive = false; bool _liveActivityManualSession = false; String? _liveActivitySessionId; @@ -1184,13 +1192,206 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } void _scheduleLiveActivitySync({bool immediate = false}) { - if (_isDisposed || !_liveActivityService.isSupportedPlatform) return; + 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}) { + if (_isDisposed || !_watchBridge.isSupportedPlatform) return; + _watchBridge.schedule(_buildWatchSnapshot, immediate: immediate); + } + + /// 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 = _resolveLiveActivityPhase(); + final repeaterState = _buildLiveActivityRepeaters(); + final now = DateTime.now(); + + final core = LiveActivitySnapshot( + sessionId: _liveActivitySessionId ?? 'idle', + mode: _liveActivityModeTitle, + phase: phase.phase, + phaseTitle: phase.title, + phaseDetail: phase.detail, + phaseEndsAt: phase.endsAt, + 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(now), + controls: _buildWatchControls(), + pingColor: _resolveWatchPingColor(), + updatedAt: now, + ); + } + + WatchGeo _buildWatchGeo(DateTime now) { + final position = _resolveWatchPosition(); + + // Repeaters heard during the current cycle get the highlight ring. + final heardIds = _liveActivityRepeaters + .map((r) => r.repeaterId.toUpperCase()) + .toSet(); + + // "Recently responded" is the newest TX ping that actually got answers. + final answered = _txPings.lastWhere( + (p) => p.heardRepeaters.isNotEmpty, + orElse: () => TxPing( + latitude: 0, + longitude: 0, + power: 0, + timestamp: now, + deviceId: '', + ), + ); + + final repeaterById = { + for (final repeater in _repeaters) ...{ + repeater.id: repeater, + if (repeater.hexId.isNotEmpty) repeater.hexId.toUpperCase(): repeater, + } + }; + + return WatchGeo( + you: position, + pings: WatchGeoBuilder.buildPings(txPings: _txPings, rxPings: _rxPings), + repeaters: WatchGeoBuilder.buildRepeaters( + repeaters: _repeaters, + heardThisCycle: heardIds, + lat: position?.lat, + lon: position?.lon, + ), + heard: WatchGeoBuilder.buildHeard( + heard: answered.heardRepeaters, + repeaterById: repeaterById, + at: answered.timestamp, + lat: position?.lat, + lon: position?.lon, + ), + linkedRepeaterIds: + answered.heardRepeaters.map((r) => r.repeaterId).toList(), + ); + } + + /// Current fix, held still until it moves meaningfully. + /// + /// Returning the previous position leaves the payload fingerprint unchanged, + /// so the bridge's dedupe suppresses the send. A parked phone therefore + /// stops talking to the watch instead of streaming GPS jitter at it. + WatchPosition? _resolveWatchPosition() { + final position = _currentPosition; + if (position == null) return _lastWatchPosition; + + final previous = _lastWatchPosition; + if (previous != null && + !WatchGeoBuilder.movedEnough( + lastLat: previous.lat, + lastLon: previous.lon, + lat: position.latitude, + lon: position.longitude, + )) { + return previous; + } + + 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; + } + + WatchControls _buildWatchControls() { + final cooldownMs = _manualPingCooldownTimer.remainingMs; + final String? blockedReason; + if (!isConnected) { + blockedReason = 'Not connected'; + } else if (!hasGpsLock) { + blockedReason = 'No GPS fix'; + } else { + blockedReason = null; + } + + return WatchControls( + canStartStop: isConnected, + canManualPing: canPing && cooldownMs <= 0, + isSessionActive: _autoPingEnabled, + manualCooldownEndsAt: cooldownMs > 0 + ? DateTime.now().add(Duration(milliseconds: cooldownMs)) + : null, + blockedReason: blockedReason, + ); + } + + /// Colour of the most recent completed ping, matching the map's markers. + WatchColor? _resolveWatchPingColor() { + if (_txPings.isEmpty) return null; + final latest = _txPings.last; + return WatchGeoBuilder.pingColor('tx', latest.heardRepeaters.isNotEmpty); + } + + /// Applies an intent from the wrist. + /// + /// 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. + Future _handleWatchCommand(WatchCommandKind kind) async { + if (_isDisposed) return 'App closing'; + + switch (kind) { + case WatchCommandKind.requestSnapshot: + _scheduleWatchSync(immediate: true); + return null; + + case WatchCommandKind.startSession: + if (!isConnected) return 'Not connected'; + if (_autoPingEnabled) return null; // Already running. + final started = await toggleAutoPing(_autoMode); + return started ? null : 'Could not start'; + + case WatchCommandKind.stopSession: + if (!_autoPingEnabled) return null; // Already stopped. + await toggleAutoPing(_autoMode); + return null; + + case WatchCommandKind.manualPing: + if (!isConnected) return 'Not connected'; + if (!hasGpsLock) return 'No GPS fix'; + if (_manualPingCooldownTimer.remainingMs > 0) return 'Cooling down'; + final sent = await sendPing(); + return sent ? null : 'Ping failed'; + } + } + LiveActivitySnapshot? _buildLiveActivitySnapshot() { final sessionId = _liveActivitySessionId; if (!_liveActivitySessionActive || sessionId == null) { @@ -1593,6 +1794,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (_liveActivityService.isSupportedPlatform) { _timerListenable.addListener(_handleLiveActivityTimerChange); } + if (_watchBridge.isSupportedPlatform) { + _watchBridge.attachCommandHandler(_handleWatchCommand); + } // Initialize debug logging (enabled by default, respects user preference) await _initDebugLogs(); @@ -8356,6 +8560,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; _timerListenable.removeListener(_handleLiveActivityTimerChange); _liveActivityService.dispose(); + _watchBridge.dispose(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.cancel(); _connectionSubscription?.cancel(); diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart new file mode 100644 index 0000000..65b9594 --- /dev/null +++ b/lib/services/watch/watch_bridge_service.dart @@ -0,0 +1,210 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../../utils/debug_logger_io.dart'; +import 'watch_models.dart'; + +typedef WatchSnapshotBuilder = WatchSnapshot? Function(); + +/// Handles a command from the wrist. Returns null when accepted, or a reason +/// string when refused — the reason is shown on the watch. +typedef WatchCommandHandler = Future Function(WatchCommandKind kind); + +/// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. +/// +/// Deliberately mirrors [LiveActivityService]'s shape — fingerprint dedupe, +/// urgency bypass, minimum non-urgent interval — because that pattern is +/// already proven in this app. The one addition is a movement gate: GPS +/// updates arrive continuously while driving, and forwarding every one would +/// flatten the watch battery for sub-pixel map changes. +class WatchBridgeService { + WatchBridgeService({@visibleForTesting MethodChannel? channel}) + : _channel = channel ?? const MethodChannel(_channelName); + + static const String _channelName = 'meshmapper/watch'; + + static const Duration _debounceDelay = Duration(milliseconds: 200); + static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + + final MethodChannel _channel; + + Timer? _scheduledUpdate; + WatchSnapshotBuilder? _pendingSnapshotBuilder; + WatchCommandHandler? _commandHandler; + + String? _lastPayload; + String? _lastUrgencyKey; + DateTime? _lastSentAt; + bool _disposed = false; + bool _didReconcileNativeState = false; + Future _operationChain = Future.value(); + + /// Commands already handled, so redelivery can't fire a second transmit. + final Set _handledCommandIds = {}; + + bool get isSupportedPlatform => + !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + + /// Wire up the inbound command path. Safe to call more than once. + void attachCommandHandler(WatchCommandHandler handler) { + _commandHandler = handler; + if (!isSupportedPlatform) return; + _channel.setMethodCallHandler(_handleNativeCall); + } + + Future _handleNativeCall(MethodCall call) async { + if (call.method != 'command') return null; + + final args = call.arguments; + if (args is! Map) return {'accepted': false, 'reason': 'Malformed command'}; + + final id = args['id'] as String?; + final rawKind = args['kind'] as String?; + if (id == null || rawKind == null) { + return {'accepted': false, 'reason': 'Malformed command'}; + } + + // WatchConnectivity redelivers; a duplicate must not transmit twice. + if (_handledCommandIds.contains(id)) { + return {'id': id, 'accepted': true, 'reason': null}; + } + + final kind = WatchCommandKind.fromWire(rawKind); + if (kind == null) { + return {'id': id, 'accepted': false, 'reason': 'Unsupported command'}; + } + + final handler = _commandHandler; + if (handler == null) { + return {'id': id, 'accepted': false, 'reason': 'App not ready'}; + } + + _rememberCommandId(id); + + try { + final refusal = await handler(kind); + // A refused command may legitimately be retried once conditions change. + if (refusal != null) _handledCommandIds.remove(id); + return {'id': id, 'accepted': refusal == null, 'reason': refusal}; + } catch (error) { + _handledCommandIds.remove(id); + debugError('[WATCH] Command $rawKind failed: $error'); + return {'id': id, 'accepted': false, 'reason': 'Command failed'}; + } + } + + void _rememberCommandId(String id) { + _handledCommandIds.add(id); + // Unbounded growth would leak across a long session. + if (_handledCommandIds.length > 64) { + _handledCommandIds.remove(_handledCommandIds.first); + } + } + + void schedule( + WatchSnapshotBuilder 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('[WATCH] 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 _clear(); + return; + } + + final payload = snapshot.toMap(); + + // updatedAt is metadata for staleness, not a visible state change. + // Excluding it stops timer ticks from causing native updates; the watch + // renders countdowns from the absolute phaseEndsAt deadline instead. + final fingerprint = Map.from(payload) + ..remove('updatedAtMs'); + final encoded = jsonEncode(fingerprint); + 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 { + await _channel.invokeMethod('sync', { + 'payload': payload, + 'urgent': urgent, + }); + _didReconcileNativeState = true; + _lastPayload = encoded; + _lastUrgencyKey = snapshot.urgencyKey; + _lastSentAt = DateTime.now(); + } on MissingPluginException { + // Expected on non-iOS hosts and in tests. + } on PlatformException catch (error) { + debugError('[WATCH] Sync failed: ${error.code}: ${error.message}'); + } catch (error) { + debugError('[WATCH] Unexpected sync failure: $error'); + } + } + + Future _clear() async { + try { + await _channel.invokeMethod('clear'); + } on MissingPluginException { + // Expected on non-iOS hosts and in tests. + } on PlatformException catch (error) { + debugError('[WATCH] Clear failed: ${error.code}: ${error.message}'); + } catch (error) { + debugError('[WATCH] Unexpected clear failure: $error'); + } finally { + _didReconcileNativeState = true; + _lastPayload = null; + _lastUrgencyKey = null; + _lastSentAt = null; + } + } + + void dispose() { + _disposed = true; + _scheduledUpdate?.cancel(); + _scheduledUpdate = null; + _pendingSnapshotBuilder = null; + _commandHandler = null; + } +} diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart new file mode 100644 index 0000000..0314d18 --- /dev/null +++ b/lib/services/watch/watch_geo_builder.dart @@ -0,0 +1,213 @@ +import 'dart:math' as math; + +import '../../models/ping_data.dart'; +import '../../models/repeater.dart'; +import '../../utils/ping_colors.dart'; +import 'watch_models.dart'; + +/// Pure builders for the geographic half of a [WatchSnapshot]. +/// +/// Kept free of provider and platform dependencies so the caps, decimation, +/// and colour resolution can be unit-tested directly — those are exactly the +/// rules that would otherwise only fail on a wrist, in a car, at speed. +class WatchGeoBuilder { + WatchGeoBuilder._(); + + /// Great-circle distance in metres. + /// + /// Local rather than `Geolocator.distanceBetween` to keep this file free of + /// plugin imports; the maths is identical. + static double distanceMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const earthRadius = 6371000.0; + final dLat = _toRadians(lat2 - lat1); + final dLon = _toRadians(lon2 - lon1); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return earthRadius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + static double _toRadians(double degrees) => degrees * math.pi / 180.0; + + /// Colour for a ping marker, matching the iOS map's `_coverageStatusColor`. + static WatchColor pingColor(String kind, bool success) { + switch (kind) { + case 'tx': + return WatchColor.fromColor( + success ? PingColors.txSuccess : PingColors.txFail, + ); + case 'rx': + return WatchColor.fromColor(PingColors.rx); + case 'disc': + return WatchColor.fromColor( + success ? PingColors.discSuccess : PingColors.discFail, + ); + case 'trace': + return WatchColor.fromColor( + success ? PingColors.traceSuccess : PingColors.noResponse, + ); + default: + return WatchColor.fromColor(PingColors.noResponse); + } + } + + /// Colour for a repeater pin, matching the iOS map's `_repeaterStatusColor`. + static WatchColor repeaterColor(Repeater repeater) { + if (repeater.isDead) return WatchColor.fromColor(PingColors.repeaterDead); + if (repeater.isNew) return WatchColor.fromColor(PingColors.repeaterNew); + return WatchColor.fromColor(PingColors.repeaterActive); + } + + /// Most recent pings, newest first, capped at [WatchWire.maxPings]. + /// + /// TX and RX are merged into one time-ordered stream because the watch map + /// shows them together; a TX that nobody answered is drawn as a failure. + static List buildPings({ + required List txPings, + required List rxPings, + int cap = WatchWire.maxPings, + }) { + final pings = []; + + for (var i = 0; i < txPings.length; i++) { + final tx = txPings[i]; + final success = tx.heardRepeaters.isNotEmpty; + pings.add(WatchPing( + id: 'tx-${tx.timestamp.millisecondsSinceEpoch}-$i', + lat: tx.latitude, + lon: tx.longitude, + kind: 'tx', + color: pingColor('tx', success), + at: tx.timestamp, + )); + } + + for (var i = 0; i < rxPings.length; i++) { + final rx = rxPings[i]; + pings.add(WatchPing( + id: 'rx-${rx.timestamp.millisecondsSinceEpoch}-$i', + lat: rx.latitude, + lon: rx.longitude, + kind: 'rx', + color: pingColor('rx', true), + at: rx.timestamp, + )); + } + + pings.sort((a, b) => b.at.compareTo(a.at)); + if (pings.length <= cap) return pings; + return pings.sublist(0, cap); + } + + /// Repeaters nearest [lat]/[lon], capped at [WatchWire.maxRepeaters]. + /// + /// Repeaters at the API's `(0, 0)` "location unknown" sentinel are excluded: + /// plotting them would drop a pin in the Gulf of Guinea. + static List buildRepeaters({ + required List repeaters, + required Set heardThisCycle, + double? lat, + double? lon, + int cap = WatchWire.maxRepeaters, + }) { + final located = repeaters.where((r) => r.hasLocation).toList(); + + if (lat != null && lon != null) { + // Distance is computed once per repeater rather than inside the + // comparator: this runs on every rebuild during an active session, and + // a zone can hold hundreds of repeaters. + final ranked = located + .map((r) => ( + repeater: r, + distance: distanceMeters(lat, lon, r.lat, r.lon), + )) + .toList() + ..sort((a, b) => a.distance.compareTo(b.distance)); + located + ..clear() + ..addAll(ranked.map((e) => e.repeater)); + } + + final limited = located.length > cap ? located.sublist(0, cap) : located; + + return limited + .map((r) => WatchRepeater( + id: r.id, + name: r.name, + lat: r.lat, + lon: r.lon, + color: repeaterColor(r), + heardThisCycle: + heardThisCycle.contains(r.id) || + heardThisCycle.contains(r.hexId), + )) + .toList(); + } + + /// Recently-responded rows, strongest SNR first, capped at + /// [WatchWire.maxHeard]. + /// + /// The cap is what the payload carries, not what the watch displays — the + /// view renders as many as fit legibly at the wearer's text size and + /// scrolls for the rest. + static List buildHeard({ + required List heard, + required Map repeaterById, + required DateTime at, + double? lat, + double? lon, + int cap = WatchWire.maxHeard, + }) { + final sorted = List.from(heard) + ..sort((a, b) => (b.snr ?? -999).compareTo(a.snr ?? -999)); + + final limited = sorted.length > cap ? sorted.sublist(0, cap) : sorted; + + return limited.map((h) { + final repeater = repeaterById[h.repeaterId]; + double? distance; + if (lat != null && + lon != null && + repeater != null && + repeater.hasLocation) { + distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); + } + + return WatchHeardNode( + id: h.repeaterId, + name: repeater?.name ?? h.repeaterId.toUpperCase(), + snr: h.snr, + rssi: h.rssi, + hops: h.pathHops?.length, + seenCount: h.seenCount, + at: at, + distanceM: distance, + snrColor: h.snr == null + ? null + : WatchColor.fromColor(PingColors.snrColor(h.snr!)), + ); + }).toList(); + } + + /// True when the fix moved far enough to be worth an update. + /// + /// A stationary GPS jitters by a few metres indefinitely; without this gate + /// a parked phone would keep the watch radio busy for no visible change. + static bool movedEnough({ + required double? lastLat, + required double? lastLon, + required double lat, + required double lon, + double thresholdMeters = WatchWire.minMoveMeters, + }) { + if (lastLat == null || lastLon == null) return true; + return distanceMeters(lastLat, lastLon, lat, lon) >= thresholdMeters; + } +} diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart new file mode 100644 index 0000000..ecc9955 --- /dev/null +++ b/lib/services/watch/watch_models.dart @@ -0,0 +1,320 @@ +import 'dart:ui' show Color; + +import '../live_activity/live_activity_models.dart'; + +/// Wire contract for the watchOS companion. +/// +/// The Swift mirror lives in `ios/Shared/MeshMapperWatchPayload.swift` and is +/// compiled into both Runner and the watch target. This file and that one are +/// a matched pair — change one, change the other, and update the golden +/// fixtures in `test/services/watch/`. +/// +/// [LiveActivitySnapshot] is reused verbatim for the session core rather than +/// re-deriving phase/counter semantics, which is the expensive and bug-prone +/// part. This snapshot composes it with the geography, controls, and haptic +/// cue the Live Activity has no use for. +class WatchWire { + WatchWire._(); + + /// Bump when a field changes meaning or is removed. The watch refuses + /// payloads it doesn't understand rather than rendering something wrong. + static const int version = 1; + + static const int maxPings = 60; + static const int maxRepeaters = 20; + static const int maxHeard = 7; + + /// Skip a geo-only update unless the fix moved at least this far. Phase + /// changes and new pings always go through; this only suppresses the + /// jitter of a stationary GPS. + static const double minMoveMeters = 15.0; +} + +/// An sRGB colour resolved from the active colour-vision palette. +/// +/// Resolving on the phone is deliberate: Dart owns [PingColors], so the watch +/// renders accessibility palettes correctly without duplicating any of them. +class WatchColor { + const WatchColor(this.r, this.g, this.b); + + factory WatchColor.fromColor(Color color) => WatchColor( + (color.r * 255.0).roundToDouble() / 255.0, + (color.g * 255.0).roundToDouble() / 255.0, + (color.b * 255.0).roundToDouble() / 255.0, + ); + + final double r; + final double g; + final double b; + + Map toMap() => {'r': r, 'g': g, 'b': b}; + + @override + bool operator ==(Object other) => + other is WatchColor && other.r == r && other.g == g && other.b == b; + + @override + int get hashCode => Object.hash(r, g, b); +} + +class WatchPosition { + const WatchPosition({ + required this.lat, + required this.lon, + required this.fixedAt, + this.headingDeg, + this.accuracyM, + }); + + final double lat; + final double lon; + final double? headingDeg; + final double? accuracyM; + final DateTime fixedAt; + + Map toMap() => { + 'lat': lat, + 'lon': lon, + 'headingDeg': headingDeg, + 'accuracyM': accuracyM, + 'fixedAtMs': fixedAt.millisecondsSinceEpoch.toDouble(), + }; +} + +class WatchPing { + const WatchPing({ + required this.id, + required this.lat, + required this.lon, + required this.kind, + required this.color, + required this.at, + }); + + final String id; + final double lat; + final double lon; + + /// 'tx' | 'rx' | 'disc' | 'trace' — drives glyph choice, not colour. + final String kind; + final WatchColor color; + final DateTime at; + + Map toMap() => { + 'id': id, + 'lat': lat, + 'lon': lon, + 'kind': kind, + 'color': color.toMap(), + 'atMs': at.millisecondsSinceEpoch.toDouble(), + }; +} + +class WatchRepeater { + const WatchRepeater({ + required this.id, + required this.name, + required this.lat, + required this.lon, + required this.color, + required this.heardThisCycle, + }); + + final String id; + final String name; + final double lat; + final double lon; + final WatchColor color; + final bool heardThisCycle; + + Map toMap() => { + 'id': id, + 'name': name, + 'lat': lat, + 'lon': lon, + 'color': color.toMap(), + 'heardThisCycle': heardThisCycle, + }; +} + +class WatchHeardNode { + const WatchHeardNode({ + required this.id, + required this.name, + required this.seenCount, + required this.at, + this.snr, + this.rssi, + this.hops, + this.distanceM, + this.snrColor, + }); + + final String id; + final String name; + final double? snr; + final int? rssi; + + /// null = direct echo; otherwise the hop count. + final int? hops; + final int seenCount; + final DateTime at; + final double? distanceM; + final WatchColor? snrColor; + + Map toMap() => { + 'id': id, + 'name': name, + 'snr': snr, + 'rssi': rssi, + 'hops': hops, + 'seenCount': seenCount, + 'atMs': at.millisecondsSinceEpoch.toDouble(), + 'distanceM': distanceM, + 'snrColor': snrColor?.toMap(), + }; +} + +class WatchGeo { + const WatchGeo({ + required this.pings, + required this.repeaters, + required this.heard, + required this.linkedRepeaterIds, + this.you, + }); + + final WatchPosition? you; + final List pings; + final List repeaters; + final List heard; + final List linkedRepeaterIds; + + Map toMap() => { + 'you': you?.toMap(), + 'pings': pings.map((p) => p.toMap()).toList(), + 'repeaters': repeaters.map((r) => r.toMap()).toList(), + 'heard': heard.map((h) => h.toMap()).toList(), + 'linkedRepeaterIds': linkedRepeaterIds, + }; +} + +/// What the wrist may do right now. +/// +/// Drives button enablement only. The phone revalidates every command, so a +/// stale payload can never talk it into an illegal transmit. +class WatchControls { + const WatchControls({ + required this.canStartStop, + required this.canManualPing, + required this.isSessionActive, + this.manualCooldownEndsAt, + this.blockedReason, + }); + + final bool canStartStop; + final bool canManualPing; + final bool isSessionActive; + final DateTime? manualCooldownEndsAt; + final String? blockedReason; + + Map toMap() => { + 'canStartStop': canStartStop, + 'canManualPing': canManualPing, + 'isSessionActive': isSessionActive, + 'manualCooldownEndsAtMs': + manualCooldownEndsAt?.millisecondsSinceEpoch.toDouble(), + 'blockedReason': blockedReason, + }; +} + +/// A one-shot event the watch should feel. +/// +/// Carries an [id] so the watch fires exactly once: diffing state would +/// double-fire on redelivery, which WatchConnectivity does routinely. +class WatchHapticCue { + const WatchHapticCue({required this.id, required this.kind}); + + final String id; + + /// 'success' | 'failure' | 'notification' + final String kind; + + Map toMap() => {'id': id, 'kind': kind}; +} + +/// The complete state the watch renders. +class WatchSnapshot { + const WatchSnapshot({ + required this.core, + required this.geo, + required this.controls, + required this.updatedAt, + this.pingColor, + this.cue, + }); + + /// Session core, reused from the Live Activity so both surfaces agree. + final LiveActivitySnapshot core; + final WatchGeo geo; + final WatchControls controls; + final WatchColor? pingColor; + final WatchHapticCue? cue; + final DateTime updatedAt; + + Map toMap() => { + 'wireVersion': WatchWire.version, + 'sessionId': core.sessionId, + 'mode': core.mode, + 'phase': core.phase.wireValue, + 'phaseTitle': core.phaseTitle, + 'phaseDetail': core.phaseDetail, + 'phaseEndsAtMs': + core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), + 'isConnected': core.isConnected, + 'zoneCode': core.zoneCode, + 'txCount': core.txCount, + 'rxCount': core.rxCount, + 'discoveryCount': core.discoveryCount, + 'traceCount': core.traceCount, + 'queueSize': core.queueSize, + 'pingColor': pingColor?.toMap(), + 'geo': geo.toMap(), + 'controls': controls.toMap(), + 'cue': cue?.toMap(), + 'updatedAtMs': updatedAt.millisecondsSinceEpoch.toDouble(), + }; + + /// Fields that must bypass the update throttle. + /// + /// Deliberately excludes geo: a moving GPS would otherwise mark every + /// update urgent and defeat the throttle entirely. + String get urgencyKey => [ + core.sessionId, + core.mode, + core.phase.wireValue, + core.phaseTitle, + core.phaseDetail ?? '', + core.phaseEndsAt?.millisecondsSinceEpoch ?? 0, + core.isConnected, + controls.canStartStop, + controls.canManualPing, + controls.isSessionActive, + cue?.id ?? '', + ].join('|'); +} + +/// An intent from the wrist. Never state — the phone decides what happens. +enum WatchCommandKind { + startSession, + stopSession, + manualPing, + requestSnapshot; + + static WatchCommandKind? fromWire(String value) { + for (final kind in WatchCommandKind.values) { + if (kind.name == value) return kind; + } + return null; + } +} diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart new file mode 100644 index 0000000..1d24224 --- /dev/null +++ b/test/services/watch/watch_geo_builder_test.dart @@ -0,0 +1,279 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/ping_data.dart'; +import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/services/watch/watch_geo_builder.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; +import 'package:mesh_mapper/utils/ping_colors.dart'; + +TxPing _tx(DateTime at, {List heard = const []}) => TxPing( + latitude: 47.6, + longitude: -122.3, + power: 22, + timestamp: at, + deviceId: 'dev', + heardRepeaters: List.of(heard), + ); + +RxPing _rx(DateTime at) => RxPing( + latitude: 47.61, + longitude: -122.31, + repeaterId: '4e', + timestamp: at, + snr: 5.0, + rssi: -90, + ); + +Repeater _repeater({ + required String id, + required double lat, + required double lon, + String name = 'Rep', + String hexId = '', + int? createdAt, + int? staleTime, +}) => + Repeater( + id: id, + hexId: hexId, + name: name, + lat: lat, + lon: lon, + lastHeard: DateTime.now().millisecondsSinceEpoch ~/ 1000, + enabled: 1, + createdAt: createdAt, + staleTime: staleTime, + ); + +void main() { + setUp(() => PingColors.setColorVisionType(ColorVisionType.none)); + + group('buildPings', () { + test('merges TX and RX newest-first and caps the list', () { + final base = DateTime(2026, 8, 12, 10); + final tx = List.generate(40, (i) => _tx(base.add(Duration(minutes: i)))); + final rx = List.generate(40, (i) => _rx(base.add(Duration(seconds: i)))); + + final pings = WatchGeoBuilder.buildPings(txPings: tx, rxPings: rx); + + expect(pings.length, WatchWire.maxPings); + for (var i = 1; i < pings.length; i++) { + expect( + pings[i - 1].at.isAfter(pings[i].at) || + pings[i - 1].at.isAtSameMomentAs(pings[i].at), + isTrue, + reason: 'pings must be newest-first', + ); + } + }); + + test('an unanswered TX is coloured as a failure', () { + final answered = _tx( + DateTime(2026, 8, 12, 10, 1), + heard: const [HeardRepeater(repeaterId: '4e', snr: 6)], + ); + final ignored = _tx(DateTime(2026, 8, 12, 10)); + + final pings = WatchGeoBuilder.buildPings( + txPings: [answered, ignored], + rxPings: const [], + ); + + final byTime = {for (final p in pings) p.at: p}; + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.txSuccess), + ); + expect( + byTime[ignored.timestamp]!.color, + WatchColor.fromColor(PingColors.txFail), + ); + }); + }); + + group('buildRepeaters', () { + test('excludes the (0,0) "location unknown" sentinel', () { + final repeaters = [ + _repeater(id: 'a', lat: 0, lon: 0), + _repeater(id: 'b', lat: 47.6, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + heardThisCycle: const {}, + ); + + expect(built.map((r) => r.id), ['b']); + }); + + test('orders by distance from the fix and caps the list', () { + final repeaters = [ + _repeater(id: 'far', lat: 48.6, lon: -122.3), + _repeater(id: 'near', lat: 47.601, lon: -122.3), + _repeater(id: 'mid', lat: 47.7, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + heardThisCycle: const {}, + lat: 47.6, + lon: -122.3, + cap: 2, + ); + + expect(built.map((r) => r.id), ['near', 'mid']); + }); + + test('marks heard-this-cycle by either short id or hex id', () { + final repeaters = [ + _repeater(id: '01', hexId: 'AA11', lat: 47.6, lon: -122.3), + _repeater(id: '02', hexId: 'BB22', lat: 47.6, lon: -122.3), + _repeater(id: '03', hexId: 'CC33', lat: 47.6, lon: -122.3), + ]; + + final built = WatchGeoBuilder.buildRepeaters( + repeaters: repeaters, + heardThisCycle: {'01', 'BB22'}, + ); + + expect( + {for (final r in built) r.id: r.heardThisCycle}, + {'01': true, '02': true, '03': false}, + ); + }); + }); + + group('buildHeard', () { + test('sorts by SNR descending and caps at the wire limit', () { + final heard = List.generate( + 12, + (i) => HeardRepeater(repeaterId: 'r$i', snr: i.toDouble()), + ); + + final built = WatchGeoBuilder.buildHeard( + heard: heard, + repeaterById: const {}, + at: DateTime(2026, 8, 12), + ); + + expect(built.length, WatchWire.maxHeard); + expect(built.first.snr, 11.0); + expect(built.last.snr, 5.0); + }); + + test('a null SNR sorts last rather than crashing', () { + final built = WatchGeoBuilder.buildHeard( + heard: const [ + HeardRepeater(repeaterId: 'quiet'), + HeardRepeater(repeaterId: 'loud', snr: 3), + ], + repeaterById: const {}, + at: DateTime(2026, 8, 12), + ); + + expect(built.map((h) => h.id), ['loud', 'quiet']); + expect(built.last.snrColor, isNull); + }); + + test('resolves name and distance from the repeater directory', () { + final built = WatchGeoBuilder.buildHeard( + heard: const [HeardRepeater(repeaterId: '4e', snr: 6, rssi: -80)], + repeaterById: { + '4e': _repeater(id: '4e', name: 'Capitol Hill', lat: 47.61, lon: -122.3), + }, + at: DateTime(2026, 8, 12), + lat: 47.6, + lon: -122.3, + ); + + expect(built.single.name, 'Capitol Hill'); + expect(built.single.distanceM, closeTo(1112, 50)); + }); + + test('falls back to the uppercased id when the repeater is unknown', () { + final built = WatchGeoBuilder.buildHeard( + heard: const [HeardRepeater(repeaterId: 'ab')], + repeaterById: const {}, + at: DateTime(2026, 8, 12), + ); + + expect(built.single.name, 'AB'); + expect(built.single.distanceM, isNull); + }); + + test('direct echoes report no hop count', () { + final built = WatchGeoBuilder.buildHeard( + heard: const [ + HeardRepeater(repeaterId: 'direct', snr: 9), + HeardRepeater(repeaterId: 'relayed', snr: 8, pathHops: ['aa', 'bb']), + ], + repeaterById: const {}, + at: DateTime(2026, 8, 12), + ); + + expect(built[0].hops, isNull); + expect(built[1].hops, 2); + }); + }); + + group('movedEnough', () { + test('always sends the first fix', () { + expect( + WatchGeoBuilder.movedEnough( + lastLat: null, + lastLon: null, + lat: 47.6, + lon: -122.3, + ), + isTrue, + ); + }); + + test('suppresses stationary GPS jitter', () { + expect( + WatchGeoBuilder.movedEnough( + lastLat: 47.6, + lastLon: -122.3, + lat: 47.60002, + lon: -122.30002, + ), + isFalse, + ); + }); + + test('passes once the fix moves past the threshold', () { + expect( + WatchGeoBuilder.movedEnough( + lastLat: 47.6, + lastLon: -122.3, + lat: 47.6005, + lon: -122.3, + ), + isTrue, + ); + }); + }); + + group('colour vision', () { + test('ping colours follow the active palette', () { + PingColors.setColorVisionType(ColorVisionType.none); + final standard = WatchGeoBuilder.pingColor('tx', true); + + PingColors.setColorVisionType(ColorVisionType.protanopia); + final protan = WatchGeoBuilder.pingColor('tx', true); + + expect(protan, isNot(standard)); + expect(protan, WatchColor.fromColor(PingColors.txSuccess)); + }); + + test('every palette resolves all ping kinds without throwing', () { + for (final type in ColorVisionType.values) { + PingColors.setColorVisionType(type); + for (final kind in ['tx', 'rx', 'disc', 'trace', 'bogus']) { + for (final success in [true, false]) { + expect(WatchGeoBuilder.pingColor(kind, success), isA()); + } + } + } + }); + }); +} diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart new file mode 100644 index 0000000..16cab7d --- /dev/null +++ b/test/services/watch/watch_wire_contract_test.dart @@ -0,0 +1,244 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/watch/watch_bridge_service.dart'; +import 'package:mesh_mapper/services/watch/watch_models.dart'; + +/// These tests guard the Dart↔Swift contract. +/// +/// `ios/Shared/MeshMapperWatchPayload.swift` decodes this JSON with a synthesised +/// `Codable`, which matches on exact key names. A rename on either side compiles +/// fine and fails silently at runtime on the wrist — so the key set is asserted +/// here rather than trusted. +WatchSnapshot _snapshot({ + WatchHapticCue? cue, + String phaseTitle = 'Listening', + bool isConnected = true, +}) => + WatchSnapshot( + core: LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Active', + phase: LiveActivityPhase.listening, + phaseTitle: phaseTitle, + phaseDetail: 'Waiting for echoes', + phaseEndsAt: DateTime.fromMillisecondsSinceEpoch(1760000000000), + isConnected: isConnected, + zoneCode: 'SEA', + txCount: 3, + rxCount: 2, + discoveryCount: 1, + traceCount: 0, + queueSize: 4, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), + ), + geo: const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), + controls: const WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + ), + pingColor: const WatchColor(1, 0, 0), + cue: cue, + updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), + ); + +void main() { + group('wire contract', () { + test('top-level keys match the Swift WatchSnapshot', () { + final map = _snapshot().toMap(); + + expect( + map.keys.toSet(), + { + 'wireVersion', + 'sessionId', + 'mode', + 'phase', + 'phaseTitle', + 'phaseDetail', + 'phaseEndsAtMs', + 'isConnected', + 'zoneCode', + 'txCount', + 'rxCount', + 'discoveryCount', + 'traceCount', + 'queueSize', + 'pingColor', + 'geo', + 'controls', + 'cue', + 'updatedAtMs', + }, + ); + }); + + test('nested keys match the Swift structs', () { + final map = _snapshot().toMap(); + + expect( + (map['geo']! as Map).keys.toSet(), + {'you', 'pings', 'repeaters', 'heard', 'linkedRepeaterIds'}, + ); + expect( + (map['controls']! as Map).keys.toSet(), + { + 'canStartStop', + 'canManualPing', + 'isSessionActive', + 'manualCooldownEndsAtMs', + 'blockedReason', + }, + ); + expect((map['pingColor']! as Map).keys.toSet(), {'r', 'g', 'b'}); + }); + + test('timestamps are epoch milliseconds as doubles', () { + final map = _snapshot().toMap(); + expect(map['updatedAtMs'], isA()); + expect(map['phaseEndsAtMs'], 1760000000000.0); + }); + + test('wire version is stamped so the watch can refuse unknown payloads', () { + expect(_snapshot().toMap()['wireVersion'], WatchWire.version); + }); + + test('urgency key ignores geo but tracks phase, controls, and cues', () { + final base = _snapshot(); + expect(_snapshot().urgencyKey, base.urgencyKey); + + expect( + _snapshot(phaseTitle: 'Sending').urgencyKey, + isNot(base.urgencyKey), + ); + expect( + _snapshot(isConnected: false).urgencyKey, + isNot(base.urgencyKey), + ); + expect( + _snapshot(cue: const WatchHapticCue(id: 'c1', kind: 'success')) + .urgencyKey, + isNot(base.urgencyKey), + ); + }); + }); + + group('command kinds', () { + test('round-trip through the wire names Swift sends', () { + for (final kind in WatchCommandKind.values) { + expect(WatchCommandKind.fromWire(kind.name), kind); + } + }); + + test('an unknown command is rejected rather than guessed at', () { + expect(WatchCommandKind.fromWire('selfDestruct'), isNull); + }); + }); + + group('bridge command handling', () { + late WatchBridgeService bridge; + late MethodChannel channel; + late List handled; + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channel = const MethodChannel('meshmapper/watch_test'); + bridge = WatchBridgeService(channel: channel); + handled = []; + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + bridge.dispose(); + }); + + Future?> sendCommand(String id, String kind) async { + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall( + MethodCall('command', {'id': id, 'kind': kind}), + ), + null, + ); + if (result == null) return null; + return channel.codec.decodeEnvelope(result) as Map?; + } + + test('accepted commands reach the handler', () async { + bridge.attachCommandHandler((kind) async { + handled.add(kind); + return null; + }); + + final reply = await sendCommand('cmd-1', 'manualPing'); + + expect(handled, [WatchCommandKind.manualPing]); + expect(reply?['accepted'], isTrue); + }); + + test('a redelivered command does not transmit twice', () async { + bridge.attachCommandHandler((kind) async { + handled.add(kind); + return null; + }); + + await sendCommand('cmd-1', 'manualPing'); + await sendCommand('cmd-1', 'manualPing'); + + expect(handled, hasLength(1), + reason: 'WatchConnectivity redelivers; a second ping must not fire'); + }); + + test('a refused command may be retried once conditions change', () async { + var refuse = true; + bridge.attachCommandHandler((kind) async { + handled.add(kind); + return refuse ? 'Not connected' : null; + }); + + final first = await sendCommand('cmd-2', 'startSession'); + expect(first?['accepted'], isFalse); + expect(first?['reason'], 'Not connected'); + + refuse = false; + final second = await sendCommand('cmd-2', 'startSession'); + expect(second?['accepted'], isTrue); + expect(handled, hasLength(2)); + }); + + test('an unknown command is refused without reaching the handler', () async { + bridge.attachCommandHandler((kind) async { + handled.add(kind); + return null; + }); + + final reply = await sendCommand('cmd-3', 'selfDestruct'); + + expect(handled, isEmpty); + expect(reply?['accepted'], isFalse); + }); + + test('a handler that throws refuses rather than crashing the bridge', + () async { + bridge.attachCommandHandler((kind) async => throw StateError('boom')); + + final reply = await sendCommand('cmd-4', 'manualPing'); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Command failed'); + }); + }); +} From b2bedbed70aaa98bb381323c10506d6b8bf4b5d4 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 17:48:12 -0700 Subject: [PATCH 05/71] Add watch map page with pings, repeaters, and follow mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Apple Watch companion. The map is Apple's basemap with MeshMapper's data drawn on top: ping squares in the phone-resolved ping colours, repeater pins with a highlight ring for those heard this cycle, optional lines from the fix to each responding repeater, and a fix puck with heading. A countdown pill renders the phase deadline via Text(timerInterval:). MeshMapper's own basemap cannot come along — MKTileOverlay is API_UNAVAILABLE(watchos), so the OpenFreeMap styles, ArcGIS satellite raster, and coverage vector tiles have no route onto the wrist. Only the data layer is ours; a satellite toggle uses Apple imagery instead. The fix is drawn as a custom annotation rather than UserAnnotation, so the watch renders the *phone's* position and needs no location permission of its own. Follow mode recentres on the fix, yields when the wearer pans, and drifts back after 8s. Two bugs fixed while verifying it: the initial `.automatic` camera settle was misread as a pan (suspending follow before the first fix arrived), and the resume deadline was only ever read during a render, so a stationary phone — which sends no updates — would stay unfollowed indefinitely. Initial span is ~3 km rather than ~1 km: wardriving is about what is around you, and the tighter default opened with every nearby repeater off-screen. SampleSnapshot is DEBUG-only behind a launch argument (-MeshMapperSampleData YES). The simulator has no Bluetooth and so can never produce pings or repeaters, which would leave the map permanently empty there. Verified excluded from Release by building the watch target in Release. Known: Apple Maps basemap tiles do not load in the watch simulator — watchOS proxies tile requests through the paired phone and the simulator's companion proxy returns GEOErrorDomain -11. Annotations, geometry, and camera are all verified; the basemap itself needs real hardware. --- ios/MeshMapperWatch/ContentView.swift | 158 +--------- ios/MeshMapperWatch/DebugPage.swift | 145 ++++++++++ ios/MeshMapperWatch/MapPage.swift | 286 +++++++++++++++++++ ios/MeshMapperWatch/MeshMapperWatchApp.swift | 2 + ios/MeshMapperWatch/SampleSnapshot.swift | 125 ++++++++ ios/MeshMapperWatch/SettingsPage.swift | 30 ++ ios/MeshMapperWatch/WatchSessionClient.swift | 8 + ios/MeshMapperWatch/WatchSettings.swift | 77 +++++ ios/Runner.xcodeproj/project.pbxproj | 20 ++ 9 files changed, 702 insertions(+), 149 deletions(-) create mode 100644 ios/MeshMapperWatch/DebugPage.swift create mode 100644 ios/MeshMapperWatch/MapPage.swift create mode 100644 ios/MeshMapperWatch/SampleSnapshot.swift create mode 100644 ios/MeshMapperWatch/SettingsPage.swift create mode 100644 ios/MeshMapperWatch/WatchSettings.swift diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index aed783b..1a0552a 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -1,159 +1,19 @@ import SwiftUI -/// Phase 2 debug dump. +/// Root shell. /// -/// Deliberately ugly: this exists to prove the transport carries real state -/// and that commands round-trip with an ack. Phase 3 replaces it with the map. +/// The map is always page one and full-bleed. Phase 4 adds the node list — +/// either as a sheet over this map or as its own page, per +/// `WatchSettings.nodeListPlacement` — and replaces the debug page. struct ContentView: View { @Environment(WatchSessionClient.self) private var client 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) - // The whole dump dims when the phone has gone quiet, so stale data is - // never mistaken for live data. - .opacity(client.isStale ? 0.45 : 1.0) - } - } - - 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(red: color.r, green: color.g, blue: color.b)) - .frame(width: 8, height: 8) - } - Text(s.mode).font(.caption2) - // Absolute deadline rendered locally — no per-second traffic. - 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) { - if let c = node.snrColor { - Circle() - .fill(Color(red: c.r, green: c.g, blue: c.b)) - .frame(width: 5, height: 5) - } - Text(node.name).font(.system(size: 10)).lineLimit(1) - Spacer() - if let snr = node.snr { - Text(String(format: "%.1f", snr)) - .font(.system(size: 10).monospacedDigit()) - } - } - } + TabView { + MapPage() + DebugPage() + SettingsPage() } - } - - 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") { - client.send(sessionActive ? .stopSession : .startSession) - } - .disabled(!(client.snapshot?.controls.canStartStop ?? false)) - - Button("Manual ping") { - client.send(.manualPing) - } - .disabled(!(client.snapshot?.controls.canManualPing ?? false)) - - Button("Refresh") { - client.send(.requestSnapshot) - } - } - .font(.caption2) - .buttonStyle(.bordered) - } - - private var sessionActive: Bool { - client.snapshot?.controls.isSessionActive ?? false + .tabViewStyle(.verticalPage) } } diff --git a/ios/MeshMapperWatch/DebugPage.swift b/ios/MeshMapperWatch/DebugPage.swift new file mode 100644 index 0000000..e2be8fe --- /dev/null +++ b/ios/MeshMapperWatch/DebugPage.swift @@ -0,0 +1,145 @@ +import SwiftUI + +/// 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 + + 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) + } + } + + 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) { + if let c = node.snrColor { + Circle().fill(Color(c)).frame(width: 5, height: 5) + } + Text(node.name).font(.system(size: 10)).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") { + client.send(sessionActive ? .stopSession : .startSession) + } + .disabled(!(client.snapshot?.controls.canStartStop ?? false)) + + Button("Manual ping") { client.send(.manualPing) } + .disabled(!(client.snapshot?.controls.canManualPing ?? false)) + + Button("Refresh") { client.send(.requestSnapshot) } + } + .font(.caption2) + .buttonStyle(.bordered) + } + + private var sessionActive: Bool { + client.snapshot?.controls.isSessionActive ?? false + } +} diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift new file mode 100644 index 0000000..e352938 --- /dev/null +++ b/ios/MeshMapperWatch/MapPage.swift @@ -0,0 +1,286 @@ +import CoreLocation +import MapKit +import SwiftUI + +/// 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. +struct MapPage: View { + @Environment(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings + + @State private var camera: MapCameraPosition = .automatic + + /// Centre we last drove the camera to, so a camera change can be attributed + /// to the wearer rather than to our own follow updates. + @State private var programmaticCenter: CLLocationCoordinate2D? + @State private var followSuspendedUntil: Date? + + /// Metres of disagreement before a camera change counts as a real pan. + private static let panTolerance: CLLocationDistance = 40 + + /// How long a pan pauses following before the map drifts back to the fix. + private static let resumeFollowAfter: TimeInterval = 8 + + 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 { + guard settings.follow else { return false } + if let until = followSuspendedUntil, until > Date() { return false } + return true + } + + var body: some View { + ZStack(alignment: .top) { + map + chrome + } + .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in + recenterIfFollowing() + } + .onAppear { recenterIfFollowing() } + } + + // MARK: - Map + + private var map: some View { + Map(position: $camera, interactionModes: [.pan, .zoom]) { + linkLines + pingMarkers + repeaterPins + fixMarker + } + .mapStyle(settings.satellite ? .imagery : .standard) + .onMapCameraChange(frequency: .onEnd) { context in + noteCameraChange(context.region.center) + } + .ignoresSafeArea(edges: .bottom) + } + + @MapContentBuilder + private var linkLines: some MapContent { + if settings.showLinks, let fix, let snapshot { + let linked = Set(snapshot.geo.linkedRepeaterIds) + ForEach(snapshot.geo.repeaters.filter { linked.contains($0.id) }) { repeater in + MapPolyline(coordinates: [ + fix, + CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon), + ]) + .stroke(Color(repeater.color).opacity(0.7), lineWidth: 1.5) + } + } + } + + @MapContentBuilder + private var pingMarkers: some MapContent { + if let snapshot { + ForEach(snapshot.geo.pings) { ping in + Annotation("", coordinate: CLLocationCoordinate2D(latitude: ping.lat, longitude: ping.lon)) { + // Squares, matching the iOS map's ping markers. + Rectangle() + .fill(Color(ping.color)) + .frame(width: 5, height: 5) + } + .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 { + if let fix, let you = snapshot?.geo.you { + Annotation("", coordinate: fix) { + // 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: - Chrome + + private var chrome: some View { + HStack(alignment: .top) { + if let snapshot { + CountdownPill(snapshot: snapshot) + } + Spacer() + if !isFollowing, fix != nil { + Button { + followSuspendedUntil = nil + recenterIfFollowing(force: true) + } label: { + Image(systemName: "location.fill") + .font(.system(size: 10)) + } + .buttonStyle(.borderless) + .padding(4) + .background(.black.opacity(0.5), in: Circle()) + } + } + .padding(.horizontal, 6) + .opacity(client.isStale ? 0.5 : 1.0) + } + + // MARK: - Camera + + private func recenterIfFollowing(force: Bool = false) { + guard force || isFollowing, let fix else { return } + programmaticCenter = fix + withAnimation(.easeInOut(duration: 0.25)) { + camera = .region( + MKCoordinateRegion( + center: fix, + span: currentSpan + ) + ) + } + } + + /// Preserve whatever zoom the wearer picked with the Digital Crown. + /// + /// The initial span is deliberately wide (~3 km): wardriving is about what + /// is around you, and a tighter default opens with every nearby repeater + /// off-screen. + @State private var currentSpan = MKCoordinateSpan( + latitudeDelta: 0.03, + longitudeDelta: 0.03 + ) + + private func noteCameraChange(_ center: CLLocationCoordinate2D) { + guard let expected = programmaticCenter else { + // We have never driven the camera, so this is `.automatic` settling on + // launch rather than a pan. Treating it as one would suspend following + // before the first fix even arrives. + return + } + guard distance(center, expected) > Self.panTolerance else { + // Our own follow update landing. + return + } + + // The wearer moved the map. Stop fighting them, and drift back shortly. + programmaticCenter = center + let deadline = Date().addingTimeInterval(Self.resumeFollowAfter) + followSuspendedUntil = deadline + scheduleFollowResume(at: deadline) + } + + /// Re-evaluate when the suspension lapses. + /// + /// `followSuspendedUntil` is only read during a render, and a stationary + /// phone sends no updates to trigger one — without this the map would stay + /// unfollowed indefinitely after a single pan. + private func scheduleFollowResume(at deadline: Date) { + resumeTask?.cancel() + resumeTask = Task { @MainActor in + let seconds = deadline.timeIntervalSinceNow + if seconds > 0 { + try? await Task.sleep(for: .seconds(seconds)) + } + guard !Task.isCancelled, followSuspendedUntil == deadline else { return } + followSuspendedUntil = nil + recenterIfFollowing() + } + } + + @State private var resumeTask: Task? + + private func distance( + _ a: CLLocationCoordinate2D, + _ b: CLLocationCoordinate2D + ) -> CLLocationDistance { + CLLocation(latitude: a.latitude, longitude: a.longitude) + .distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude)) + } +} + +// 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)) + } + } +} + +private struct CountdownPill: View { + let snapshot: WatchSnapshot + + var body: some View { + HStack(spacing: 3) { + if let color = snapshot.pingColor { + Circle() + .fill(Color(color)) + .frame(width: 6, height: 6) + } + // Absolute deadline rendered by the system — no per-second traffic. + if let endsAt = snapshot.phaseEndsAt, endsAt > Date() { + Text(timerInterval: Date()...endsAt, countsDown: true) + .font(.system(size: 12, weight: .medium).monospacedDigit()) + } else { + Text(snapshot.phaseTitle) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.black.opacity(0.55), in: Capsule()) + } +} diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index 9fbce36..56bf539 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -11,11 +11,13 @@ import SwiftUI @main struct MeshMapperWatchApp: App { @State private var client = WatchSessionClient() + @State private var settings = WatchSettings() var body: some Scene { WindowGroup { ContentView() .environment(client) + .environment(settings) .onAppear { client.refresh() } } } diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift new file mode 100644 index 0000000..711dc32 --- /dev/null +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -0,0 +1,125 @@ +#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 +/// +/// DEBUG-only, and never reached unless that argument is passed, so it cannot +/// 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 + + static func make() -> WatchSnapshot { + let now = Date().timeIntervalSince1970 * 1000 + + 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, + name: name, + lat: originLat + dLat, + lon: originLon + dLon, + color: color, + heardThisCycle: heard + ) + } + + let heard: [WatchHeardNode] = [ + ("4E", "Capitol Hill", 8.5, -71, nil, 3, 1420.0), + ("77", "Queen Anne", 2.25, -94, 1, 2, 2310.0), + ("A2", "Beacon Hill", -4.0, -112, 2, 1, 3050.0), + ].map { id, name, snr, rssi, hops, seen, distance in + WatchHeardNode( + id: id, + name: name, + snr: snr, + rssi: rssi, + hops: hops, + seenCount: seen, + 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)) + ) + } + + return WatchSnapshot( + wireVersion: MeshMapperWatchWire.version, + sessionId: "sample", + mode: "Active", + phase: "listening", + phaseTitle: "Listening", + phaseDetail: "Waiting for echoes", + phaseEndsAtMs: now + 42_000, + isConnected: true, + zoneCode: "SEA", + txCount: 27, + rxCount: 14, + discoveryCount: 6, + traceCount: 0, + queueSize: 2, + pingColor: green, + geo: WatchGeo( + you: WatchPosition( + lat: originLat + 0.006, + lon: originLon + 0.014, + headingDeg: 72, + accuracyM: 8, + fixedAtMs: now + ), + pings: pings, + repeaters: repeaters, + heard: heard, + linkedRepeaterIds: ["4E", "77"] + ), + controls: WatchControls( + canStartStop: true, + canManualPing: true, + isSessionActive: true, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ), + cue: nil, + updatedAtMs: now + ) + } +} +#endif diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift new file mode 100644 index 0000000..99b0d23 --- /dev/null +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -0,0 +1,30 @@ +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 + + var body: some View { + @Bindable var settings = settings + + List { + Section("Map") { + Toggle("Satellite", isOn: $settings.satellite) + Toggle("Follow position", isOn: $settings.follow) + Toggle("Lines to repeaters", isOn: $settings.showLinks) + } + + Section("Layout") { + Picker("Node list", selection: $settings.nodeListPlacement) { + ForEach(WatchSettings.NodeListPlacement.allCases) { placement in + Text(placement.label).tag(placement) + } + } + } + } + .font(.caption) + } +} diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 82a7a9f..03268bc 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -42,6 +42,14 @@ final class WatchSessionClient: NSObject { /// 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() + receivedAt = Date() + return + } + #endif + guard let session else { return } session.delegate = self diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift new file mode 100644 index 0000000..6c66a1a --- /dev/null +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -0,0 +1,77 @@ +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 satellite = "map.satellite" + static let showLinks = "map.showLinks" + static let follow = "map.follow" + static let nodeListPlacement = "layout.nodeListPlacement" + } + + /// 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. The default is unsettled until it has + /// been worn — see the plan. + 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" + } + } + } + + private let defaults: UserDefaults + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + satellite = defaults.bool(forKey: Key.satellite) + 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 + nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) + .flatMap(NodeListPlacement.init(rawValue:)) ?? .sheet + } + + /// Apple imagery rather than the standard basemap. Mirrors the iOS app's + /// satellite option, though the imagery is Apple's, not ArcGIS. + var satellite: Bool { + didSet { defaults.set(satellite, forKey: Key.satellite) } + } + + /// 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) } + } + + var nodeListPlacement: NodeListPlacement { + didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) } + } +} + +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 e0ec83f..6e4fa6b 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,12 +7,17 @@ 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, ); }; }; 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 */; }; @@ -94,6 +99,7 @@ /* 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 = ""; }; @@ -101,6 +107,7 @@ 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 = ""; }; @@ -111,6 +118,8 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.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 = ""; }; @@ -123,6 +132,7 @@ 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 = ""; }; @@ -190,6 +200,11 @@ 111B35B32FAB66FA2E78E0BE /* Info.plist */, 6CC8647C002484845F02D0CE /* Assets.xcassets */, 178C77B846A00943CD881203 /* WatchSessionClient.swift */, + 3EA64785988022009D0587B1 /* MapPage.swift */, + 98C9115E48EC1A1ABBADD7C4 /* DebugPage.swift */, + 84205AF3825B4E2EF987B27E /* SettingsPage.swift */, + 8523E9AE78A9549CE601F697 /* WatchSettings.swift */, + 10A44B241BC20153F36C053D /* SampleSnapshot.swift */, ); name = MeshMapperWatch; path = MeshMapperWatch; @@ -620,6 +635,11 @@ 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; From 51332e2b386280c9fec30f99724f474ca4c2a58b Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 18:10:27 -0700 Subject: [PATCH 06/71] Add heard-node list in both placements, plus stale badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the Apple Watch companion. NodeListView shows the repeaters that answered the most recent ping — SNR-coloured dot, name, SNR, and a context line of hop count and distance — with a detail view carrying RSSI, seen count and last-heard time. Both placements read the same view, so this is a presentation toggle rather than two implementations: - sheet: the map keeps a tappable summary bar showing the strongest node inline, opening the full list over the map. - page: the map stays clean full-bleed and the list gets its own tab. The bar is a tap, not the swipe-up originally planned: on watchOS a swipe from the bottom edge is the Control Center gesture, so it would fight the system. Surfacing the strongest node inline also answers the common question — "what just answered?" — with no interaction at all. Rows use standard text styles and never shrink type to hit a row count. The payload carries up to 7; the list renders what fits at the wearer's text size and scrolls for the rest. The map chrome gains a stale badge, so data that has stopped updating can never read as live. Two DEBUG-only launch arguments (-MeshMapperShowNodeSheet, -MeshMapperInitialPage) make specific screens capturable headlessly; the simulator offers no way to tap or swipe. Verified excluded from Release. --- ios/MeshMapperWatch/ContentView.swift | 36 ++++-- ios/MeshMapperWatch/MapPage.swift | 92 ++++++++++++++- ios/MeshMapperWatch/NodeListView.swift | 151 +++++++++++++++++++++++++ ios/Runner.xcodeproj/project.pbxproj | 4 + 4 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 ios/MeshMapperWatch/NodeListView.swift diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index 1a0552a..20b9e5a 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -2,18 +2,38 @@ import SwiftUI /// Root shell. /// -/// The map is always page one and full-bleed. Phase 4 adds the node list — -/// either as a sheet over this map or as its own page, per -/// `WatchSettings.nodeListPlacement` — and replaces the debug page. +/// The map is always page one and full-bleed. 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(WatchSessionClient.self) private var client + @Environment(WatchSettings.self) private var settings + + @State private var selection = 0 var body: some View { - TabView { - MapPage() - DebugPage() - SettingsPage() + TabView(selection: $selection) { + MapPage().tag(0) + + if settings.nodeListPlacement == .page { + NavigationStack { + NodeListView() + .navigationTitle("Heard") + .navigationBarTitleDisplayMode(.inline) + } + .tag(1) + } + + DebugPage().tag(2) + SettingsPage().tag(3) } .tabViewStyle(.verticalPage) + .onAppear { + #if DEBUG + // Lets a specific page be captured headlessly for design review. + let requested = UserDefaults.standard.integer(forKey: "MeshMapperInitialPage") + if requested > 0 { selection = requested } + #endif + } } } diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index e352938..0b0a2c0 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -38,15 +38,88 @@ struct MapPage: View { return true } + @State private var showingNodes = false + var body: some View { ZStack(alignment: .top) { map chrome + if settings.nodeListPlacement == .sheet { + VStack { + Spacer() + nodeSummaryBar + } + } + } + .sheet(isPresented: $showingNodes) { + NavigationStack { + NodeListView() + .navigationTitle("Heard") + .navigationBarTitleDisplayMode(.inline) + } } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in recenterIfFollowing() } - .onAppear { recenterIfFollowing() } + .onAppear { + recenterIfFollowing() + #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 + } + #endif + } + } + + /// Tap target for the node sheet. + /// + /// A tap rather than the swipe-up the plan first assumed: on watchOS a swipe + /// from the bottom edge is the Control Center gesture, so it would fight the + /// system. Surfacing the strongest node inline also means the common case — + /// "what just answered?" — needs no interaction at all. + private var nodeSummaryBar: some View { + Button { + showingNodes = true + } label: { + HStack(spacing: 5) { + if let top = snapshot?.geo.heard.first { + if let color = top.snrColor { + Circle().fill(Color(color)).frame(width: 6, height: 6) + } + Text(top.name) + .font(.caption2) + .lineLimit(1) + if let snr = top.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.caption2.monospacedDigit()) + } + Spacer(minLength: 2) + let extra = (snapshot?.geo.heard.count ?? 0) - 1 + if extra > 0 { + Text("+\(extra)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } else { + Text("Nothing heard") + .font(.caption2) + .foregroundStyle(.secondary) + Spacer(minLength: 2) + } + Image(systemName: "chevron.up") + .font(.system(size: 8)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.black.opacity(0.6), in: Capsule()) + } + .buttonStyle(.plain) + .padding(.horizontal, 6) + .padding(.bottom, 2) + .opacity(client.isStale ? 0.5 : 1.0) } // MARK: - Map @@ -123,7 +196,22 @@ struct MapPage: View { private var chrome: some View { HStack(alignment: .top) { if let snapshot { - CountdownPill(snapshot: snapshot) + VStack(alignment: .leading, spacing: 2) { + CountdownPill(snapshot: snapshot) + if client.isStale, let receivedAt = client.receivedAt { + // Stale data must never read as live data. + HStack(spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 7)) + Text(receivedAt, style: .relative) + .font(.system(size: 9)) + } + .foregroundStyle(.orange) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.black.opacity(0.6), in: Capsule()) + } + } } Spacer() if !isFollowing, fix != nil { diff --git a/ios/MeshMapperWatch/NodeListView.swift b/ios/MeshMapperWatch/NodeListView.swift new file mode 100644 index 0000000..7700fbc --- /dev/null +++ b/ios/MeshMapperWatch/NodeListView.swift @@ -0,0 +1,151 @@ +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) { + if let color = node.snrColor { + Circle() + .fill(Color(color)) + .frame(width: 7, height: 7) + } + Text(node.name) + .font(.body) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.body.monospacedDigit()) + } + } + + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.vertical, 1) + } + + private var subtitle: String { + var parts: [String] = [node.hopLabel] + if let distance = node.distanceLabel { parts.append(distance) } + return parts.joined(separator: " · ") + } +} + +struct NodeDetailView: View { + let node: WatchHeardNode + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 5) { + if let color = node.snrColor { + Circle().fill(Color(color)).frame(width: 9, height: 9) + } + Text(node.name) + .font(.headline) + .lineLimit(2) + } + + detail("ID", node.id) + if let snr = node.snr { + detail("SNR", snr.formatted(.number.precision(.fractionLength(1))) + " dB") + } + if let rssi = node.rssi { + detail("RSSI", "\(rssi) dBm") + } + detail("Path", node.hopLabel) + detail("Seen", node.seenCount == 1 ? "once" : "\(node.seenCount)×") + 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) } + + /// `nil` hops means a direct echo — the distinction the iOS map draws too. + var hopLabel: String { + guard let hops else { return "direct" } + return hops == 1 ? "1 hop" : "\(hops) hops" + } + + 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/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6e4fa6b..5aa3494 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 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 */; }; @@ -116,6 +117,7 @@ 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 = ""; }; @@ -205,6 +207,7 @@ 84205AF3825B4E2EF987B27E /* SettingsPage.swift */, 8523E9AE78A9549CE601F697 /* WatchSettings.swift */, 10A44B241BC20153F36C053D /* SampleSnapshot.swift */, + 775B3F6C0618B4BD5644B40B /* NodeListView.swift */, ); name = MeshMapperWatch; path = MeshMapperWatch; @@ -640,6 +643,7 @@ 404769CF7E177D96A79BEA75 /* SettingsPage.swift in Sources */, 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */, 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */, + 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 73c6c030c67c07d755732bf0da3f261215a9f5e9 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 18:31:31 -0700 Subject: [PATCH 07/71] Mirror the app's Top Heard overlay on the watch map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the heard-node model to match what the phone's map actually shows. Wire version 2. The watch was inventing its own idea of "recently responded" from TxPing.heardRepeaters — names, hop counts, RSSI, seen counts. The app's map overlay (_buildTopRepeatersOverlay) shows something different and simpler: up to three rows from the latest ping plus the RX slot, each a dot coloured by which ping type was answered, the hex path hash, and the SNR. Three corrections: - Hex ID is the identity, not the name. Path hashes are 1-3 bytes, so a short ID often maps to several repeaters. Names are resolved only through indexByHexPrefix, which drops any prefix owned by more than one repeater -- a confidently wrong name is worse than none. The watch always shows the hex and treats a name as a secondary hint. - No hop counts anywhere. _updateTopRepeaters is fed directRepeaters and multiHopEvents are explicitly excluded, so multi-hop was never part of this surface. - The RX slot trails the top three rather than competing on SNR, matching the overlay's distinct trailing row. Layout follows the phone: Top Heard hard against the upper-left corner (drawing into the top safe area, which is free because the system clock is right-aligned), countdown pill bottom-right. The bottom summary bar is gone, superseded by the box. Colours come from the same OverlayPingType mapping the map uses, resolved on the phone, so colour-vision palettes carry across unchanged. --- ios/MeshMapperWatch/DebugPage.swift | 11 +- ios/MeshMapperWatch/MapPage.swift | 167 +++++++++--------- ios/MeshMapperWatch/NodeListView.swift | 67 +++---- ios/MeshMapperWatch/SampleSnapshot.swift | 19 +- ios/Shared/MeshMapperWatchPayload.swift | 31 +++- lib/providers/app_state_provider.dart | 47 +++-- lib/services/watch/watch_geo_builder.dart | 134 ++++++++++---- lib/services/watch/watch_models.dart | 48 +++-- .../watch/watch_geo_builder_test.dart | 111 ++++++++---- 9 files changed, 393 insertions(+), 242 deletions(-) diff --git a/ios/MeshMapperWatch/DebugPage.swift b/ios/MeshMapperWatch/DebugPage.swift index e2be8fe..d48957a 100644 --- a/ios/MeshMapperWatch/DebugPage.swift +++ b/ios/MeshMapperWatch/DebugPage.swift @@ -98,10 +98,15 @@ struct DebugPage: View { ForEach(s.geo.heard) { node in HStack(spacing: 4) { - if let c = node.snrColor { - Circle().fill(Color(c)).frame(width: 5, height: 5) + 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) } - Text(node.name).font(.system(size: 10)).lineLimit(1) Spacer() if let snr = node.snr { Text(String(format: "%.1f", snr)) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 0b0a2c0..df466db 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -41,15 +41,33 @@ struct MapPage: View { @State private var showingNodes = false var body: some View { - ZStack(alignment: .top) { + ZStack { map - chrome - if settings.nodeListPlacement == .sheet { - VStack { - Spacer() - nodeSummaryBar + + // Top Heard sits hard against the upper-left and the countdown against + // the lower-right, mirroring the phone's map so the two read the same + // way at a glance. + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + topHeardBox + Spacer(minLength: 0) + recenterButton + } + Spacer(minLength: 0) + HStack(alignment: .bottom) { + staleBadge + Spacer(minLength: 0) + if let snapshot { + CountdownPill(snapshot: snapshot) + } } } + .padding(.horizontal, 5) + .padding(.bottom, 3) + // Draw into the top safe area so Top Heard sits hard against the corner. + // Safe because the box is left-aligned and the system clock is right- + // aligned, so they occupy different halves of that strip. + .ignoresSafeArea(edges: .top) } .sheet(isPresented: $showingNodes) { NavigationStack { @@ -73,55 +91,69 @@ struct MapPage: View { } } - /// Tap target for the node sheet. + /// "Top Heard" — the phone's map overlay, reproduced on the wrist. /// - /// A tap rather than the swipe-up the plan first assumed: on watchOS a swipe - /// from the bottom edge is the Control Center gesture, so it would fight the - /// system. Surfacing the strongest node inline also means the common case — - /// "what just answered?" — needs no interaction at all. - private var nodeSummaryBar: some View { + /// 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 topHeardBox: some View { Button { showingNodes = true } label: { - HStack(spacing: 5) { - if let top = snapshot?.geo.heard.first { - if let color = top.snrColor { - Circle().fill(Color(color)).frame(width: 6, height: 6) - } - Text(top.name) - .font(.caption2) - .lineLimit(1) - if let snr = top.snr { - Text(snr, format: .number.precision(.fractionLength(1))) - .font(.caption2.monospacedDigit()) - } - Spacer(minLength: 2) - let extra = (snapshot?.geo.heard.count ?? 0) - 1 - if extra > 0 { - Text("+\(extra)") - .font(.caption2) - .foregroundStyle(.secondary) - } + VStack(alignment: .leading, spacing: 2) { + Text("TOP HEARD") + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(.white.opacity(0.55)) + .kerning(0.5) + + if heard.isEmpty { + Text("---") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.white.opacity(0.4)) } else { - Text("Nothing heard") - .font(.caption2) - .foregroundStyle(.secondary) - Spacer(minLength: 2) + ForEach(heard) { node in + HStack(spacing: 4) { + Circle() + .fill(Color(node.typeColor)) + .frame(width: 6, height: 6) + Text(node.id) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + if let snr = node.snr { + Text(snr, format: .number.precision(.fractionLength(1))) + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(node.snrColor.map(Color.init) ?? .white) + } + } + } } - Image(systemName: "chevron.up") - .font(.system(size: 8)) - .foregroundStyle(.secondary) } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.black.opacity(0.6), in: Capsule()) + .padding(.horizontal, 7) + .padding(.vertical, 5) + .background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) - .padding(.horizontal, 6) - .padding(.bottom, 2) .opacity(client.isStale ? 0.5 : 1.0) } + private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } + + @ViewBuilder + private var recenterButton: some View { + if !isFollowing, fix != nil { + Button { + followSuspendedUntil = nil + 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 { @@ -191,44 +223,21 @@ struct MapPage: View { } } - // MARK: - Chrome - - private var chrome: some View { - HStack(alignment: .top) { - if let snapshot { - VStack(alignment: .leading, spacing: 2) { - CountdownPill(snapshot: snapshot) - if client.isStale, let receivedAt = client.receivedAt { - // Stale data must never read as live data. - HStack(spacing: 2) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 7)) - Text(receivedAt, style: .relative) - .font(.system(size: 9)) - } - .foregroundStyle(.orange) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(.black.opacity(0.6), in: Capsule()) - } - } - } - Spacer() - if !isFollowing, fix != nil { - Button { - followSuspendedUntil = nil - recenterIfFollowing(force: true) - } label: { - Image(systemName: "location.fill") - .font(.system(size: 10)) - } - .buttonStyle(.borderless) - .padding(4) - .background(.black.opacity(0.5), in: Circle()) + /// Stale data must never read as live data. + @ViewBuilder + private var staleBadge: some View { + if client.isStale, let receivedAt = client.receivedAt { + HStack(spacing: 2) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 7)) + Text(receivedAt, style: .relative) + .font(.system(size: 9)) } + .foregroundStyle(.orange) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.black.opacity(0.65), in: Capsule()) } - .padding(.horizontal, 6) - .opacity(client.isStale ? 0.5 : 1.0) } // MARK: - Camera diff --git a/ios/MeshMapperWatch/NodeListView.swift b/ios/MeshMapperWatch/NodeListView.swift index 7700fbc..d152980 100644 --- a/ios/MeshMapperWatch/NodeListView.swift +++ b/ios/MeshMapperWatch/NodeListView.swift @@ -55,34 +55,39 @@ struct NodeRow: View { var body: some View { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 5) { - if let color = node.snrColor { - Circle() - .fill(Color(color)) - .frame(width: 7, height: 7) - } - Text(node.name) - .font(.body) - .lineLimit(1) - .truncationMode(.tail) + // 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) } } - Text(subtitle) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) + if let subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } } .padding(.vertical, 1) } - private var subtitle: String { - var parts: [String] = [node.hopLabel] + /// 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.joined(separator: " · ") + return parts.isEmpty ? nil : parts.joined(separator: " · ") } } @@ -93,23 +98,25 @@ struct NodeDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 5) { - if let color = node.snrColor { - Circle().fill(Color(color)).frame(width: 9, height: 9) - } - Text(node.name) - .font(.headline) + 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) } - detail("ID", node.id) if let snr = node.snr { detail("SNR", snr.formatted(.number.precision(.fractionLength(1))) + " dB") } - if let rssi = node.rssi { - detail("RSSI", "\(rssi) dBm") - } - detail("Path", node.hopLabel) - detail("Seen", node.seenCount == 1 ? "once" : "\(node.seenCount)×") if let distance = node.distanceLabel { detail("Distance", distance) } @@ -135,12 +142,6 @@ struct NodeDetailView: View { extension WatchHeardNode { var heardAt: Date { Date(timeIntervalSince1970: atMs / 1000) } - /// `nil` hops means a direct echo — the distinction the iOS map draws too. - var hopLabel: String { - guard let hops else { return "direct" } - return hops == 1 ? "1 hop" : "\(hops) hops" - } - var distanceLabel: String? { guard let distanceM else { return nil } if distanceM < 1000 { diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 711dc32..c6fa2aa 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -59,25 +59,28 @@ enum SampleSnapshot { ) } + // 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) let heard: [WatchHeardNode] = [ - ("4E", "Capitol Hill", 8.5, -71, nil, 3, 1420.0), - ("77", "Queen Anne", 2.25, -94, 1, 2, 2310.0), - ("A2", "Beacon Hill", -4.0, -112, 2, 1, 3050.0), - ].map { id, name, snr, rssi, hops, seen, distance in + ("4E5D", "Capitol Hill", 8.5, 1420.0, green), + ("77A1", nil, 2.25, 2310.0, teal), + ("A2", nil, -4.0, nil, green), + ("B914", "Magnolia", 5.75, 2870.0, purple), + ].map { id, name, snr, distance, typeColor in WatchHeardNode( id: id, name: name, snr: snr, - rssi: rssi, - hops: hops, - seenCount: seen, 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)) + : WatchColor(r: 0.96, g: 0.26, b: 0.21)), + typeColor: typeColor ) } diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 850b3cf..6ae7d4d 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -20,12 +20,18 @@ import Foundation enum MeshMapperWatchWire { /// Bump when a field changes meaning or is removed. The receiver refuses /// payloads it doesn't understand rather than rendering something wrong. - static let version = 1 + /// + /// 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. + static let version = 2 /// Caps, mirrored in Dart. Enforced on send *and* validated on receive. static let maxPings = 60 static let maxRepeaters = 20 - static let maxHeard = 7 + + /// Three top-SNR rows plus the RX slot. + static let maxHeard = 4 } // MARK: - Colour @@ -70,19 +76,28 @@ struct WatchRepeater: Codable, Hashable, Identifiable { let heardThisCycle: Bool } -/// A row in the "recently responded" panel. +/// 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 name: String? let snr: Double? - let rssi: Int? - /// nil = direct echo; otherwise the number of hops. - let hops: Int? - let seenCount: Int 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 { diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d08e8a7..d81e7de 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1253,28 +1253,24 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final position = _resolveWatchPosition(); // Repeaters heard during the current cycle get the highlight ring. - final heardIds = _liveActivityRepeaters + final heardIds = _topRepeatersOverlay .map((r) => r.repeaterId.toUpperCase()) .toSet(); - // "Recently responded" is the newest TX ping that actually got answers. - final answered = _txPings.lastWhere( - (p) => p.heardRepeaters.isNotEmpty, - orElse: () => TxPing( - latitude: 0, - longitude: 0, - power: 0, - timestamp: now, - deviceId: '', - ), - ); - - final repeaterById = { - for (final repeater in _repeaters) ...{ - repeater.id: repeater, - if (repeater.hexId.isNotEmpty) repeater.hexId.toUpperCase(): repeater, - } - }; + // 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 {}; return WatchGeo( you: position, @@ -1286,14 +1282,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lon: position?.lon, ), heard: WatchGeoBuilder.buildHeard( - heard: answered.heardRepeaters, - repeaterById: repeaterById, - at: answered.timestamp, + top: top, + rxSlot: rxSlot, + repeaterByHex: repeaterByHex, + at: now, lat: position?.lat, lon: position?.lon, ), - linkedRepeaterIds: - answered.heardRepeaters.map((r) => r.repeaterId).toList(), + linkedRepeaterIds: [ + for (final entry in top) entry.repeaterId, + if (rxSlot != null) rxSlot.repeaterId, + ], ); } diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 0314d18..a3ab903 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import '../../models/ping_data.dart'; import '../../models/repeater.dart'; +import '../../providers/app_state_provider.dart' show OverlayPingType; import '../../utils/ping_colors.dart'; import 'watch_models.dart'; @@ -151,49 +152,110 @@ class WatchGeoBuilder { .toList(); } - /// Recently-responded rows, strongest SNR first, capped at - /// [WatchWire.maxHeard]. + /// Dot colour for an overlay row, mirroring `_overlayTypeColor` on the map. + static WatchColor overlayTypeColor(OverlayPingType type) => switch (type) { + OverlayPingType.tx => WatchColor.fromColor(PingColors.txSuccess), + OverlayPingType.disc => WatchColor.fromColor(PingColors.discSuccess), + OverlayPingType.trace => WatchColor.fromColor(PingColors.traceSuccess), + OverlayPingType.rx => WatchColor.fromColor(PingColors.rx), + }; + + /// The "Top Heard" overlay rows: up to three top-SNR repeaters from the + /// latest ping, then the current RX slot. /// - /// The cap is what the payload carries, not what the watch displays — the - /// view renders as many as fit legibly at the wearer's text size and - /// scrolls for the rest. + /// Order is deliberate rather than a global SNR sort — it matches the map + /// overlay, where the RX slot is a distinct trailing row rather than a + /// competitor for the top three. static List buildHeard({ - required List heard, - required Map repeaterById, + required List<({String repeaterId, double snr, OverlayPingType type})> top, + ({String repeaterId, double snr})? rxSlot, + required Map repeaterByHex, required DateTime at, double? lat, double? lon, - int cap = WatchWire.maxHeard, }) { - final sorted = List.from(heard) - ..sort((a, b) => (b.snr ?? -999).compareTo(a.snr ?? -999)); - - final limited = sorted.length > cap ? sorted.sublist(0, cap) : sorted; - - return limited.map((h) { - final repeater = repeaterById[h.repeaterId]; - double? distance; - if (lat != null && - lon != null && - repeater != null && - repeater.hasLocation) { - distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); - } - - return WatchHeardNode( - id: h.repeaterId, - name: repeater?.name ?? h.repeaterId.toUpperCase(), - snr: h.snr, - rssi: h.rssi, - hops: h.pathHops?.length, - seenCount: h.seenCount, + final rows = []; + + for (final entry in top) { + rows.add(_row( + id: entry.repeaterId, + snr: entry.snr, + type: entry.type, + repeaterByHex: repeaterByHex, at: at, - distanceM: distance, - snrColor: h.snr == null - ? null - : WatchColor.fromColor(PingColors.snrColor(h.snr!)), - ); - }).toList(); + lat: lat, + lon: lon, + )); + } + + if (rxSlot != null) { + rows.add(_row( + id: rxSlot.repeaterId, + snr: rxSlot.snr, + type: OverlayPingType.rx, + repeaterByHex: repeaterByHex, + at: at, + lat: lat, + lon: lon, + )); + } + + return rows.length > WatchWire.maxHeard + ? rows.sublist(0, WatchWire.maxHeard) + : rows; + } + + static WatchHeardNode _row({ + required String id, + required double snr, + required OverlayPingType type, + required Map repeaterByHex, + required DateTime at, + double? lat, + double? lon, + }) { + final hex = id.toUpperCase(); + final repeater = repeaterByHex[hex]; + + double? distance; + if (lat != null && lon != null && repeater != null && repeater.hasLocation) { + distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); + } + + return WatchHeardNode( + id: hex, + // Null rather than a guess: a short path hash can match several + // repeaters, and a confidently wrong name is worse than none. + name: repeater?.name, + snr: snr, + at: at, + distanceM: distance, + snrColor: WatchColor.fromColor(PingColors.snrColor(snr)), + typeColor: overlayTypeColor(type), + ); + } + + /// Index repeaters by the hex prefix an overlay row would carry. + /// + /// Only unambiguous prefixes are kept: if two repeaters share the leading + /// hex at that length, neither is resolvable, which is exactly the condition + /// the app flags as ambiguous rather than papering over. + static Map indexByHexPrefix( + List repeaters, + int length, + ) { + final counts = {}; + final index = {}; + + for (final repeater in repeaters) { + if (repeater.hexId.length < length) continue; + final prefix = repeater.hexId.substring(0, length).toUpperCase(); + counts[prefix] = (counts[prefix] ?? 0) + 1; + index[prefix] = repeater; + } + + index.removeWhere((prefix, _) => (counts[prefix] ?? 0) > 1); + return index; } /// True when the fix moved far enough to be worth an update. diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index ecc9955..b40f80e 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -18,11 +18,17 @@ class WatchWire { /// Bump when a field changes meaning or is removed. The watch refuses /// payloads it doesn't understand rather than rendering something wrong. - static const int version = 1; + /// + /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and + /// ping-type colour — instead of the richer per-echo data. Hop counts are + /// gone: the overlay is fed `directRepeaters` only. + static const int version = 2; static const int maxPings = 60; static const int maxRepeaters = 20; - static const int maxHeard = 7; + + /// Three top-SNR rows plus the RX slot, matching `_buildTopRepeatersOverlay`. + static const int maxHeard = 4; /// Skip a geo-only update unless the fix moved at least this far. Phase /// changes and new pings always go through; this only suppresses the @@ -137,41 +143,53 @@ class WatchRepeater { }; } +/// One row of the "Top Heard" overlay. +/// +/// Mirrors `_buildTopRepeatersOverlay` in `map_widget.dart`: a dot coloured by +/// which kind of ping the repeater answered, the hex path-hash ID, and the SNR. +/// +/// The **ID is the identity**, not the name. Path hashes are 1–3 bytes, so a +/// 2-character ID frequently cannot be resolved to a single repeater — [name] +/// is sent only when the match is unambiguous, and the watch always shows the +/// hex. +/// +/// There is no hop count here by design: the overlay is fed `directRepeaters`, +/// with multi-hop events deliberately excluded. class WatchHeardNode { const WatchHeardNode({ required this.id, - required this.name, - required this.seenCount, + required this.typeColor, required this.at, + this.name, this.snr, - this.rssi, - this.hops, this.distanceM, this.snrColor, }); + /// Uppercase hex path hash, 2/4/6 chars depending on the zone's hop bytes. final String id; - final String name; - final double? snr; - final int? rssi; - /// null = direct echo; otherwise the hop count. - final int? hops; - final int seenCount; + /// Resolved repeater name, when the hex maps to exactly one repeater. + final String? name; + final double? snr; final DateTime at; final double? distanceM; + + /// SNR traffic-light colour. final WatchColor? snrColor; + /// Ping type the repeater answered — green flood/active, teal discovery, + /// cyan trace, purple most-recent RX. + final WatchColor typeColor; + Map toMap() => { 'id': id, 'name': name, 'snr': snr, - 'rssi': rssi, - 'hops': hops, - 'seenCount': seenCount, 'atMs': at.millisecondsSinceEpoch.toDouble(), 'distanceM': distanceM, 'snrColor': snrColor?.toMap(), + 'typeColor': typeColor.toMap(), }; } diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index 1d24224..c6980c4 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/models/ping_data.dart'; import 'package:mesh_mapper/models/repeater.dart'; +import 'package:mesh_mapper/providers/app_state_provider.dart' show OverlayPingType; import 'package:mesh_mapper/services/watch/watch_geo_builder.dart'; import 'package:mesh_mapper/services/watch/watch_models.dart'; import 'package:mesh_mapper/utils/ping_colors.dart'; @@ -142,76 +143,114 @@ void main() { }); }); - group('buildHeard', () { - test('sorts by SNR descending and caps at the wire limit', () { - final heard = List.generate( - 12, - (i) => HeardRepeater(repeaterId: 'r$i', snr: i.toDouble()), + group('buildHeard — mirrors the map\'s Top Heard overlay', () { + test('keeps the overlay order, with the RX slot trailing', () { + final built = WatchGeoBuilder.buildHeard( + top: const [ + (repeaterId: '4E5D', snr: 8.5, type: OverlayPingType.tx), + (repeaterId: '77A1', snr: 2.25, type: OverlayPingType.disc), + ], + rxSlot: (repeaterId: 'B914', snr: 9.9), + repeaterByHex: const {}, + at: DateTime(2026, 8, 12), ); + // The RX slot trails even though its SNR is highest — it is a distinct + // row on the map, not a competitor for the top three. + expect(built.map((h) => h.id), ['4E5D', '77A1', 'B914']); + }); + + test('the RX row is purple and ping rows carry their own type colour', () { final built = WatchGeoBuilder.buildHeard( - heard: heard, - repeaterById: const {}, + top: const [ + (repeaterId: 'AA', snr: 1, type: OverlayPingType.tx), + (repeaterId: 'BB', snr: 1, type: OverlayPingType.disc), + ], + rxSlot: (repeaterId: 'CC', snr: 1), + repeaterByHex: const {}, at: DateTime(2026, 8, 12), ); - expect(built.length, WatchWire.maxHeard); - expect(built.first.snr, 11.0); - expect(built.last.snr, 5.0); + expect(built[0].typeColor, WatchColor.fromColor(PingColors.txSuccess)); + expect(built[1].typeColor, WatchColor.fromColor(PingColors.discSuccess)); + expect(built[2].typeColor, WatchColor.fromColor(PingColors.rx)); }); - test('a null SNR sorts last rather than crashing', () { + test('never exceeds the wire cap of three rows plus the RX slot', () { final built = WatchGeoBuilder.buildHeard( - heard: const [ - HeardRepeater(repeaterId: 'quiet'), - HeardRepeater(repeaterId: 'loud', snr: 3), + top: const [ + (repeaterId: 'A', snr: 4, type: OverlayPingType.tx), + (repeaterId: 'B', snr: 3, type: OverlayPingType.tx), + (repeaterId: 'C', snr: 2, type: OverlayPingType.tx), + (repeaterId: 'D', snr: 1, type: OverlayPingType.tx), ], - repeaterById: const {}, + rxSlot: (repeaterId: 'E', snr: 0), + repeaterByHex: const {}, at: DateTime(2026, 8, 12), ); - expect(built.map((h) => h.id), ['loud', 'quiet']); - expect(built.last.snrColor, isNull); + expect(built.length, WatchWire.maxHeard); }); - test('resolves name and distance from the repeater directory', () { + test('uppercases the hex id and resolves name plus distance', () { final built = WatchGeoBuilder.buildHeard( - heard: const [HeardRepeater(repeaterId: '4e', snr: 6, rssi: -80)], - repeaterById: { - '4e': _repeater(id: '4e', name: 'Capitol Hill', lat: 47.61, lon: -122.3), + top: const [(repeaterId: '4e5d', snr: 6, type: OverlayPingType.tx)], + repeaterByHex: { + '4E5D': _repeater( + id: '01', hexId: '4E5D82', name: 'Capitol Hill', lat: 47.61, lon: -122.3), }, at: DateTime(2026, 8, 12), lat: 47.6, lon: -122.3, ); + expect(built.single.id, '4E5D'); expect(built.single.name, 'Capitol Hill'); expect(built.single.distanceM, closeTo(1112, 50)); }); - test('falls back to the uppercased id when the repeater is unknown', () { + test('leaves the name null when the hash resolves to nothing', () { final built = WatchGeoBuilder.buildHeard( - heard: const [HeardRepeater(repeaterId: 'ab')], - repeaterById: const {}, + top: const [(repeaterId: 'AB', snr: 1, type: OverlayPingType.tx)], + repeaterByHex: const {}, at: DateTime(2026, 8, 12), ); - expect(built.single.name, 'AB'); + expect(built.single.id, 'AB'); + expect(built.single.name, isNull, + reason: 'a confidently wrong name is worse than none'); expect(built.single.distanceM, isNull); }); + }); - test('direct echoes report no hop count', () { - final built = WatchGeoBuilder.buildHeard( - heard: const [ - HeardRepeater(repeaterId: 'direct', snr: 9), - HeardRepeater(repeaterId: 'relayed', snr: 8, pathHops: ['aa', 'bb']), - ], - repeaterById: const {}, - at: DateTime(2026, 8, 12), - ); + group('indexByHexPrefix', () { + test('resolves a prefix owned by exactly one repeater', () { + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3, name: 'A'), + _repeater(id: '02', hexId: '77A1B0', lat: 47.6, lon: -122.3, name: 'B'), + ], 4); + + expect(index['4E5D']?.name, 'A'); + expect(index['77A1']?.name, 'B'); + }); + + test('drops prefixes shared by more than one repeater', () { + // The exact collision a 1-byte path hash produces. + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3, name: 'A'), + _repeater(id: '02', hexId: '4E99F1', lat: 47.6, lon: -122.3, name: 'B'), + ], 2); + + expect(index['4E'], isNull, + reason: 'ambiguous prefixes must resolve to no name at all'); + }); + + test('ignores repeaters whose hex is shorter than the prefix', () { + final index = WatchGeoBuilder.indexByHexPrefix([ + _repeater(id: '01', hexId: '4E', lat: 47.6, lon: -122.3, name: 'A'), + ], 4); - expect(built[0].hops, isNull); - expect(built[1].hops, 2); + expect(index, isEmpty); }); }); From cef0e6b07c620bbb0f7aaa259db205bcf6bb5a35 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 18:44:47 -0700 Subject: [PATCH 08/71] Fit Top Heard on the smallest watch and drop the countdown into the corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two placement and legibility fixes on the watch map. The countdown sat well clear of the bottom edge because the overlay still honoured the bottom safe area while the map beneath it ignored it. The overlay now ignores both edges, so the two corners are actually corners. Top Heard is sized for the worst case rather than the sample case. A 3-byte zone yields six-character path hashes, and four of those at full size ran the box across a 40 mm screen. Row type now shrinks with ID length exactly as RepeaterIdChip does on the phone (11/10/9 pt for 2/4/6 characters), rows are pinned to a single line, and the box is held to a fixed type size: it is a HUD, and at large accessibility sizes it would otherwise swallow the map. The scrollable detail list is where the wearer's text-size setting is honoured. Both overlays move from flat 70% black to a blurred material. The flat panel let bright basemap labels bleed through and fight the SNR digits — visible as a road label crossing the fourth row. Blurring removes the competing detail and matches the platform's own overlay treatment. Verified at the worst case — four six-character IDs on a 40 mm SE — via a new DEBUG launch argument, -MeshMapperLongIds YES. --- ios/MeshMapperWatch/MapPage.swift | 47 ++++++++++++++++++------ ios/MeshMapperWatch/SampleSnapshot.swift | 11 ++++-- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index df466db..41c1fd5 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -63,11 +63,12 @@ struct MapPage: View { } } .padding(.horizontal, 5) - .padding(.bottom, 3) - // Draw into the top safe area so Top Heard sits hard against the corner. - // Safe because the box is left-aligned and the system clock is right- - // aligned, so they occupy different halves of that strip. - .ignoresSafeArea(edges: .top) + .padding(.bottom, 4) + // Draw into both safe areas so the two corners are actually corners. + // The top strip is free because the box is left-aligned and the system + // clock is right-aligned; the bottom strip is otherwise dead space that + // was pushing the countdown well clear of the edge. + .ignoresSafeArea(edges: [.top, .bottom]) } .sheet(isPresented: $showingNodes) { NavigationStack { @@ -108,7 +109,7 @@ struct MapPage: View { if heard.isEmpty { Text("---") - .font(.system(size: 11, design: .monospaced)) + .font(.system(size: rowFontSize, design: .monospaced)) .foregroundStyle(.white.opacity(0.4)) } else { ForEach(heard) { node in @@ -117,25 +118,48 @@ struct MapPage: View { .fill(Color(node.typeColor)) .frame(width: 6, height: 6) Text(node.id) - .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .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: 11, weight: .semibold, design: .monospaced)) + .font(.system(size: rowFontSize, weight: .semibold, design: .monospaced)) .foregroundStyle(node.snrColor.map(Color.init) ?? .white) } } + .lineLimit(1) + .fixedSize() } } } .padding(.horizontal, 7) .padding(.vertical, 5) - .background(.black.opacity(0.7), in: RoundedRectangle(cornerRadius: 8)) + // Blurred material rather than flat translucency: a 70% black panel + // lets bright basemap labels bleed through and fight the SNR digits. + // Blurring the map behind the box removes the competing detail entirely. + .background(.ultraThinMaterial, in: .rect(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, 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) } + /// Shrink the rows for longer path hashes, the same way `RepeaterIdChip` + /// does on the phone. A 3-byte zone yields 6-character IDs, which at full + /// size would run the box across a 40 mm screen. + private var rowFontSize: CGFloat { + let widest = heard.map(\.id.count).max() ?? 2 + if widest > 4 { return 9 } + if widest > 2 { return 10 } + return 11 + } + private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @ViewBuilder @@ -376,8 +400,9 @@ private struct CountdownPill: View { .lineLimit(1) } } - .padding(.horizontal, 6) + .padding(.horizontal, 7) .padding(.vertical, 3) - .background(.black.opacity(0.55), in: Capsule()) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) } } diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index c6fa2aa..5e1d794 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -63,11 +63,14 @@ enum SampleSnapshot { // 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] = [ - ("4E5D", "Capitol Hill", 8.5, 1420.0, green), - ("77A1", nil, 2.25, 2310.0, teal), - ("A2", nil, -4.0, nil, green), - ("B914", "Magnolia", 5.75, 2870.0, purple), + (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, From ce6af9abff42f010bf42933b5f77e229d184e7b6 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 19:08:09 -0700 Subject: [PATCH 09/71] Move the map overlay to a full-width bottom panel with a countdown bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both overlays were clipped by the display curvature on a real watch. The simulator renders a flat rectangle and never showed it — .ignoresSafeArea was reaching for corners that physically do not exist. Rather than nudging insets, the two overlays become one panel across the bottom of the map, inside the safe area, with no corner to lose: - A depleting bar across the top, draining right to left, with the remaining time beside it. Fed by phaseEndsAt and a new phaseDurationMs, both absolute, so the bar is correct between updates and correct when the app opens midway through a phase. CountdownTimerService gains a durationMs getter and the provider identifies the owning timer by matching end times. - Heard rows in two columns when they fit, one when they do not. ViewThatFits decides by measurement: a 3-byte zone's six-character hashes plus SNR cannot fit two columns on 40 mm, and the hex ID must never truncate — it is the repeater's identity. Two layout traps worth recording. A Rectangle rule is a greedy child and stretched the panel to its cap; spacing replaces it. And .frame(maxWidth:) is expansive rather than merely limiting, so capping the panel made it that wide always — only the phase title, which can run long, carries a ceiling now. The translucent material is kept, and the whole panel remains the tap target for the detail list. --- ios/MeshMapperWatch/MapPage.swift | 221 ++++++++++-------- ios/MeshMapperWatch/SampleSnapshot.swift | 1 + ios/Shared/MeshMapperWatchPayload.swift | 15 ++ lib/providers/app_state_provider.dart | 23 ++ lib/services/countdown_timer_service.dart | 6 + lib/services/watch/watch_models.dart | 9 + .../watch/watch_wire_contract_test.dart | 10 + 7 files changed, 191 insertions(+), 94 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 41c1fd5..fdd3c1d 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -44,31 +44,23 @@ struct MapPage: View { ZStack { map - // Top Heard sits hard against the upper-left and the countdown against - // the lower-right, mirroring the phone's map so the two read the same - // way at a glance. - VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .top) { - topHeardBox + // One panel in the top-leading corner carrying phase and Top Heard. + // + // Earlier versions floated the countdown in the opposite corner and drew + // into the safe areas to reach the edges. On real hardware both got + // clipped by the display curvature — the simulator renders a flat + // rectangle and never shows it. The safe area is honoured now, and + // merging the two overlays means there is no second corner to lose. + VStack(spacing: 0) { + HStack { Spacer(minLength: 0) recenterButton } Spacer(minLength: 0) - HStack(alignment: .bottom) { - staleBadge - Spacer(minLength: 0) - if let snapshot { - CountdownPill(snapshot: snapshot) - } - } + statusPanel } - .padding(.horizontal, 5) - .padding(.bottom, 4) - // Draw into both safe areas so the two corners are actually corners. - // The top strip is free because the box is left-aligned and the system - // clock is right-aligned; the bottom strip is otherwise dead space that - // was pushing the countdown well clear of the edge. - .ignoresSafeArea(edges: [.top, .bottom]) + .padding(.horizontal, 4) + .padding(.top, 2) } .sheet(isPresented: $showingNodes) { NavigationStack { @@ -92,53 +84,50 @@ struct MapPage: View { } } - /// "Top Heard" — the phone's map overlay, reproduced on the wrist. + /// 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 topHeardBox: some View { + private var statusPanel: some View { Button { showingNodes = true } label: { - VStack(alignment: .leading, spacing: 2) { - Text("TOP HEARD") - .font(.system(size: 8, weight: .medium)) - .foregroundStyle(.white.opacity(0.55)) - .kerning(0.5) + VStack(alignment: .leading, spacing: 4) { + timerBar if heard.isEmpty { - Text("---") - .font(.system(size: rowFontSize, design: .monospaced)) - .foregroundStyle(.white.opacity(0.4)) + Text("Nothing heard") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.45)) + .frame(maxWidth: .infinity, alignment: .leading) } else { - ForEach(heard) { node in - HStack(spacing: 4) { - 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) - } + // Two columns when they fit, one when they don't. A 3-byte zone's + // six-character hashes plus SNR will not fit two columns on a 40 mm + // screen, and truncating the ID is not an option — it is the + // repeater's identity. ViewThatFits picks by measurement rather than + // by a guess about screen size. + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: 10) { + heardColumn(Array(heard.prefix(2))) + heardColumn(Array(heard.dropFirst(2))) + } + VStack(alignment: .leading, spacing: 2) { + ForEach(heard) { heardRow($0) } } - .lineLimit(1) - .fixedSize() } + .frame(maxWidth: .infinity, alignment: .leading) } } - .padding(.horizontal, 7) - .padding(.vertical, 5) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) // Blurred material rather than flat translucency: a 70% black panel // lets bright basemap labels bleed through and fight the SNR digits. - // Blurring the map behind the box removes the competing detail entirely. - .background(.ultraThinMaterial, in: .rect(cornerRadius: 10, style: .continuous)) + // Blurring the map behind the panel removes the competing detail. + .background(.ultraThinMaterial, in: .rect(cornerRadius: 12, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) + RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) ) } @@ -150,6 +139,94 @@ struct MapPage: View { .opacity(client.isStale ? 0.5 : 1.0) } + /// 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: a greedy row always "fits", which would stop + /// `ViewThatFits` from ever rejecting the two-column layout. + 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 { + TimelineView(.periodic(from: .now, by: 1)) { context in + HStack(spacing: 6) { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(.white.opacity(0.18)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + .frame( + width: geo.size.width + * (snapshot.phaseRemainingFraction(at: context.date) ?? 0) + ) + } + } + .frame(height: 4) + + if let endsAt = snapshot.phaseEndsAt, endsAt > context.date { + Text(timerInterval: context.date...endsAt, countsDown: true) + .font(.system(size: 12, weight: .semibold).monospacedDigit()) + .foregroundStyle(.white) + .frame(width: 42, alignment: .trailing) + } else { + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + } + } + } + .frame(height: 14) + } + } + /// Shrink the rows for longer path hashes, the same way `RepeaterIdChip` /// does on the phone. A 3-byte zone yields 6-character IDs, which at full /// size would run the box across a 40 mm screen. @@ -247,23 +324,6 @@ struct MapPage: View { } } - /// Stale data must never read as live data. - @ViewBuilder - private var staleBadge: some View { - if client.isStale, let receivedAt = client.receivedAt { - HStack(spacing: 2) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 7)) - Text(receivedAt, style: .relative) - .font(.system(size: 9)) - } - .foregroundStyle(.orange) - .padding(.horizontal, 5) - .padding(.vertical, 2) - .background(.black.opacity(0.65), in: Capsule()) - } - } - // MARK: - Camera private func recenterIfFollowing(force: Bool = false) { @@ -379,30 +439,3 @@ private struct RepeaterPin: View { } } } - -private struct CountdownPill: View { - let snapshot: WatchSnapshot - - var body: some View { - HStack(spacing: 3) { - if let color = snapshot.pingColor { - Circle() - .fill(Color(color)) - .frame(width: 6, height: 6) - } - // Absolute deadline rendered by the system — no per-second traffic. - if let endsAt = snapshot.phaseEndsAt, endsAt > Date() { - Text(timerInterval: Date()...endsAt, countsDown: true) - .font(.system(size: 12, weight: .medium).monospacedDigit()) - } else { - Text(snapshot.phaseTitle) - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - } - } - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 0.5)) - } -} diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 5e1d794..01f42a6 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -95,6 +95,7 @@ enum SampleSnapshot { phaseTitle: "Listening", phaseDetail: "Waiting for echoes", phaseEndsAtMs: now + 42_000, + phaseDurationMs: 60_000, isConnected: true, zoneCode: "SEA", txCount: 27, diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 6ae7d4d..5b6ed37 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -151,6 +151,9 @@ struct WatchSnapshot: Codable, Hashable { 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 @@ -177,6 +180,18 @@ struct WatchSnapshot: Codable, Hashable { 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) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d81e7de..f1cefac 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1245,10 +1245,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { geo: _buildWatchGeo(now), controls: _buildWatchControls(), pingColor: _resolveWatchPingColor(), + phaseDurationMs: _phaseDurationMsFor(phase.endsAt), 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(DateTime now) { final position = _resolveWatchPosition(); diff --git a/lib/services/countdown_timer_service.dart b/lib/services/countdown_timer_service.dart index ccb6d58..cdac81b 100644 --- a/lib/services/countdown_timer_service.dart +++ b/lib/services/countdown_timer_service.dart @@ -19,6 +19,12 @@ class CountdownTimerService extends ChangeNotifier { 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/watch/watch_models.dart b/lib/services/watch/watch_models.dart index b40f80e..d5115bf 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -270,6 +270,7 @@ class WatchSnapshot { required this.updatedAt, this.pingColor, this.cue, + this.phaseDurationMs, }); /// Session core, reused from the Live Activity so both surfaces agree. @@ -280,6 +281,13 @@ class WatchSnapshot { final WatchHapticCue? cue; final DateTime updatedAt; + /// Total length of the current phase. + /// + /// With [LiveActivitySnapshot.phaseEndsAt] this is everything the watch needs + /// to draw a depleting progress bar locally — no per-second traffic, and the + /// bar stays correct even if the app opens midway through a phase. + final int? phaseDurationMs; + Map toMap() => { 'wireVersion': WatchWire.version, 'sessionId': core.sessionId, @@ -289,6 +297,7 @@ class WatchSnapshot { 'phaseDetail': core.phaseDetail, 'phaseEndsAtMs': core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), + 'phaseDurationMs': phaseDurationMs, 'isConnected': core.isConnected, 'zoneCode': core.zoneCode, 'txCount': core.txCount, diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 16cab7d..b3dc8a1 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -15,6 +15,7 @@ WatchSnapshot _snapshot({ WatchHapticCue? cue, String phaseTitle = 'Listening', bool isConnected = true, + int? phaseDurationMs, }) => WatchSnapshot( core: LiveActivitySnapshot( @@ -48,6 +49,7 @@ WatchSnapshot _snapshot({ isSessionActive: true, ), pingColor: const WatchColor(1, 0, 0), + phaseDurationMs: phaseDurationMs, cue: cue, updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), ); @@ -67,6 +69,7 @@ void main() { 'phaseTitle', 'phaseDetail', 'phaseEndsAtMs', + 'phaseDurationMs', 'isConnected', 'zoneCode', 'txCount', @@ -109,6 +112,13 @@ void main() { expect(map['phaseEndsAtMs'], 1760000000000.0); }); + test('phase duration rides along so the watch can draw its own bar', () { + // Deadline plus duration is everything needed to compute the remaining + // fraction locally, which is why the bar needs no per-second updates. + expect(_snapshot(phaseDurationMs: 45000).toMap()['phaseDurationMs'], 45000); + expect(_snapshot().toMap()['phaseDurationMs'], isNull); + }); + test('wire version is stamped so the watch can refuse unknown payloads', () { expect(_snapshot().toMap()['wireVersion'], WatchWire.version); }); From 68660359d8200c1512597db788d1708ce36ead9f Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 19:24:48 -0700 Subject: [PATCH 10/71] Overlay the countdown label on the bar and offset the camera for the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label rides on the timer bar instead of sitting beside it, with a shadow so it stays readable over both the filled and empty parts of the track. A separate column cost width permanently and left the phase title cramped. The camera now places the fix in the middle of the band between the top of the display and the top of the panel, rather than the middle of the display, so the puck is no longer pushed down behind the panel. The panel measures itself through a preference key, so this holds however tall the panel gets. Also tracks the rendered region from onMapCameraChange. Without it a Digital Crown zoom was discarded on the next follow update, since every recenter reused the originally requested span. Known incomplete: the offset under-shifts. Instrumentation on a 46 mm simulator showed panel=54pt, view=159pt, frac=0.34, latSpan=0.03 — the fraction is right, but two denominators are wrong. viewHeight measures the ZStack (159pt) while the map draws into the safe area it excludes (~242pt), and context.region.span reports the requested span rather than the visible one. Deriving both from context.rect (MKMapRect) is the fix. --- ios/MeshMapperWatch/MapPage.swift | 145 ++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 36 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index fdd3c1d..f0f192a 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -40,6 +40,26 @@ struct MapPage: View { @State private var showingNodes = false + /// Height of the bottom panel and of the whole view, so the camera can put + /// the fix in the middle of the *visible* map rather than the middle of the + /// display — otherwise the puck sits behind the panel. + @State private var panelHeight: CGFloat = 0 + @State private var viewHeight: CGFloat = 0 + + private struct PanelHeightKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } + } + + /// Fraction of the view the panel covers, clamped so a pathological layout + /// can never push the camera somewhere absurd. + private var obscuredFraction: CGFloat { + guard viewHeight > 0 else { return 0 } + return min(0.55, max(0, panelHeight / viewHeight)) + } + var body: some View { ZStack { map @@ -58,10 +78,27 @@ struct MapPage: View { } Spacer(minLength: 0) statusPanel + .background( + GeometryReader { geo in + Color.clear.preference(key: PanelHeightKey.self, value: geo.size.height) + } + ) } .padding(.horizontal, 4) .padding(.top, 2) } + .background( + GeometryReader { geo in + Color.clear + .onAppear { viewHeight = geo.size.height } + .onChange(of: geo.size.height) { _, height in viewHeight = height } + } + ) + .onPreferenceChange(PanelHeightKey.self) { height in + guard abs(height - panelHeight) > 0.5 else { return } + panelHeight = height + recenterIfFollowing() + } .sheet(isPresented: $showingNodes) { NavigationStack { NodeListView() @@ -93,7 +130,7 @@ struct MapPage: View { Button { showingNodes = true } label: { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { timerBar if heard.isEmpty { @@ -120,7 +157,7 @@ struct MapPage: View { } } .padding(.horizontal, 8) - .padding(.vertical, 6) + .padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) // Blurred material rather than flat translucency: a 70% black panel // lets bright basemap labels bleed through and fight the SNR digits. @@ -193,37 +230,40 @@ struct MapPage: View { .lineLimit(1) } else if let snapshot { TimelineView(.periodic(from: .now, by: 1)) { context in - HStack(spacing: 6) { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(.white.opacity(0.18)) - Capsule() - .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) - .frame( - width: geo.size.width - * (snapshot.phaseRemainingFraction(at: context.date) ?? 0) - ) - } + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + .frame( + width: geo.size.width + * (snapshot.phaseRemainingFraction(at: context.date) ?? 0) + ) } - .frame(height: 4) - - if let endsAt = snapshot.phaseEndsAt, endsAt > context.date { - Text(timerInterval: context.date...endsAt, countsDown: true) - .font(.system(size: 12, weight: .semibold).monospacedDigit()) - .foregroundStyle(.white) - .frame(width: 42, alignment: .trailing) - } else { - Text(snapshot.phaseTitle) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.white) - .lineLimit(1) - .truncationMode(.tail) - .layoutPriority(1) + // Label rides on the bar rather than beside it: a separate column + // costs width permanently, and the phase title needs the room. + .overlay(alignment: .trailing) { + Group { + if let endsAt = snapshot.phaseEndsAt, endsAt > context.date { + Text(timerInterval: context.date...endsAt, countsDown: true) + .font(.system(size: 11, weight: .bold).monospacedDigit()) + .frame(width: 38, alignment: .trailing) + } else { + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + } + } + .foregroundStyle(.white) + // The fill slides under the label, so a shadow keeps it readable + // against both the filled and empty parts of the track. + .shadow(color: .black.opacity(0.7), radius: 1.5) + .padding(.trailing, 6) } } } - .frame(height: 14) + .frame(height: 15) } } @@ -266,6 +306,7 @@ struct MapPage: View { } .mapStyle(settings.satellite ? .imagery : .standard) .onMapCameraChange(frequency: .onEnd) { context in + noteRenderedRegion(context.region) noteCameraChange(context.region.center) } .ignoresSafeArea(edges: .bottom) @@ -328,17 +369,28 @@ struct MapPage: View { private func recenterIfFollowing(force: Bool = false) { guard force || isFollowing, let fix else { return } - programmaticCenter = fix + let center = centerPlacing(fix) + programmaticCenter = center withAnimation(.easeInOut(duration: 0.25)) { - camera = .region( - MKCoordinateRegion( - center: fix, - span: currentSpan - ) - ) + camera = .region(MKCoordinateRegion(center: center, span: currentSpan)) } } + /// Region centre that puts [fix] in the middle of the band between the top + /// of the display and the top of the panel. + /// + /// MapKit centres the region in the whole view, so with a panel covering the + /// lower third the fix would sit low and partly behind it. Shifting the + /// region centre south by half the obscured height lifts the fix by the same + /// amount on screen. + private func centerPlacing(_ fix: CLLocationCoordinate2D) -> CLLocationCoordinate2D { + let shift = currentSpan.latitudeDelta * Double(obscuredFraction) / 2 + return CLLocationCoordinate2D( + latitude: fix.latitude - shift, + longitude: fix.longitude + ) + } + /// Preserve whatever zoom the wearer picked with the Digital Crown. /// /// The initial span is deliberately wide (~3 km): wardriving is about what @@ -349,6 +401,27 @@ struct MapPage: View { longitudeDelta: 0.03 ) + /// Track the region MapKit actually rendered. + /// + /// Two reasons this matters. MapKit fits a requested span to the view's + /// aspect ratio, so the rendered latitude delta differs from the requested + /// one — computing the panel offset against the request under-shifts the + /// camera. And without this, a Digital Crown zoom would be thrown away on + /// the next follow update. + private func noteRenderedRegion(_ region: MKCoordinateRegion) { + let previous = currentSpan.latitudeDelta + currentSpan = region.span + + // The first render is what reveals the aspect-corrected span, so re-place + // the fix once against it. This converges: the follow-up request carries + // the rendered span, so the next change reports no material difference. + guard previous > 0 else { return } + let drift = abs(region.span.latitudeDelta - previous) / previous + if drift > 0.05 { + recenterIfFollowing() + } + } + private func noteCameraChange(_ center: CLLocationCoordinate2D) { guard let expected = programmaticCenter else { // We have never driven the camera, so this is `.automatic` settling on From 5ed21aba3793875a3dc67c9aa3d4abdfa96f4449 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 19:58:33 -0700 Subject: [PATCH 11/71] Size the countdown bar to the watch, and place the fix via the map proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to the map page, both driven by what the panel actually measures rather than by a guess. Bar placement now differs by screen. A 46 mm watch has 184 pt of panel width and a 40 mm one has 138, so the large screens set the phase title and the countdown either side of the track and leave the bar as a pure gauge, while the small ones keep the single label riding on the bar. The column gutter widens on the roomy sizes too, which ViewThatFits will still veto down to one column if six-character hashes need the space. Placing the fix is now a question for the map, not a calculation. The previous offset under-shot because it compared the panel's height to the enclosing stack's, and that stack is not the map: SwiftUI reports the map's frame as 159 pt tall on a 248 pt display, yet it draws to every edge. No measurement of the view hierarchy predicts where a coordinate lands. MapReader's proxy answers directly, so the camera translates by the difference between the fix and whatever sits at the target point, then a deadbanded correction on each camera change absorbs the first render and any Digital Crown zoom. Verified: the puck settles at 78.75 pt against a panel top of 158 on 46 mm, and 47.75 against 96 on 40 mm — centred in the band, as asked, and stable across frames. Worth remembering: the CGRect preference keys must ignore empty values. Every sibling subtree contributes the default, so taking nextValue() unconditionally let a later .zero overwrite the real frame — the measurements read as zero until reduce learned to skip them. --- ios/MeshMapperWatch/MapPage.swift | 270 ++++++++++++++++++++---------- 1 file changed, 183 insertions(+), 87 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index f0f192a..4394e4f 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -40,29 +40,57 @@ struct MapPage: View { @State private var showingNodes = false - /// Height of the bottom panel and of the whole view, so the camera can put - /// the fix in the middle of the *visible* map rather than the middle of the - /// display — otherwise the puck sits behind the panel. - @State private var panelHeight: CGFloat = 0 - @State private var viewHeight: CGFloat = 0 - - private struct PanelHeightKey: PreferenceKey { - static let defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) + /// The panel's frame in global coordinates, so the camera can keep the fix + /// out from behind it. + @State private var panelFrame: CGRect = .zero + @State private var viewWidth: 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. + private struct PanelFrameKey: PreferenceKey { + static let defaultValue: CGRect = .zero + static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + let next = nextValue() + if next != .zero { value = next } } } - /// Fraction of the view the panel covers, clamped so a pathological layout - /// can never push the camera somewhere absurd. - private var obscuredFraction: CGFloat { - guard viewHeight > 0 else { return 0 } - return min(0.55, max(0, panelHeight / viewHeight)) + /// Where the fix belongs on screen: midway between the top of the display and + /// the top of the panel. + private var targetPoint: CGPoint? { + guard panelFrame.height > 0 else { return nil } + return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } + /// Width the panel's content actually gets: the display minus the overlay's + /// horizontal padding (4 a side) and the panel's own (8 a side). + private var panelContentWidth: CGFloat { max(0, viewWidth - 24) } + + /// Whether there is room to set the phase title and the countdown *beside* + /// the bar rather than on top of it. + /// + /// Budget: ~52 pt for the longest phase title, ~38 for the countdown, two + /// 6 pt gaps, and ~64 left for the bar so it still reads as a gauge. A 46 mm + /// screen has 184 pt to spend and a 40 mm one has 138, so the two sizes get + /// genuinely different treatments instead of the small one being squeezed. + private var labelsFitBesideBar: Bool { panelContentWidth >= 160 } + var body: some View { + // The proxy is the only reliable way to relate a coordinate to a point on + // screen. The map draws outside its own layout frame — it ignores the + // bottom safe area, and on a 46 mm watch the frame SwiftUI reports is + // 159 pt tall against a 248 pt display — so no measurement of the view + // hierarchy predicts where a coordinate will actually land. Asking the map + // sidesteps the whole question. + MapReader { proxy in + content(proxy) + } + } + + private func content(_ proxy: MapProxy) -> some View { ZStack { - map + map(proxy) // One panel in the top-leading corner carrying phase and Top Heard. // @@ -74,13 +102,13 @@ struct MapPage: View { VStack(spacing: 0) { HStack { Spacer(minLength: 0) - recenterButton + recenterButton(proxy) } Spacer(minLength: 0) statusPanel .background( GeometryReader { geo in - Color.clear.preference(key: PanelHeightKey.self, value: geo.size.height) + Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) } ) } @@ -90,14 +118,14 @@ struct MapPage: View { .background( GeometryReader { geo in Color.clear - .onAppear { viewHeight = geo.size.height } - .onChange(of: geo.size.height) { _, height in viewHeight = height } + .onAppear { viewWidth = geo.size.width } + .onChange(of: geo.size.width) { _, width in viewWidth = width } } ) - .onPreferenceChange(PanelHeightKey.self) { height in - guard abs(height - panelHeight) > 0.5 else { return } - panelHeight = height - recenterIfFollowing() + .onPreferenceChange(PanelFrameKey.self) { frame in + guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } + panelFrame = frame + recenterIfFollowing(proxy) } .sheet(isPresented: $showingNodes) { NavigationStack { @@ -107,10 +135,15 @@ struct MapPage: View { } } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in - recenterIfFollowing() + recenterIfFollowing(proxy) + } + // The delayed resume in `scheduleFollowResume` only clears the suspension; + // recentring happens here, where a live proxy is in scope. + .onChange(of: followSuspendedUntil) { _, until in + if until == nil { recenterIfFollowing(proxy) } } .onAppear { - recenterIfFollowing() + recenterIfFollowing(proxy) #if DEBUG // Lets the sheet layout be captured and iterated on headlessly; the // simulator has no way to tap the bar. @@ -145,7 +178,7 @@ struct MapPage: View { // repeater's identity. ViewThatFits picks by measurement rather than // by a guess about screen size. ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 10) { + HStack(alignment: .top, spacing: columnGap) { heardColumn(Array(heard.prefix(2))) heardColumn(Array(heard.dropFirst(2))) } @@ -176,6 +209,12 @@ struct MapPage: View { .opacity(client.isStale ? 0.5 : 1.0) } + /// Gutter between the two heard columns. Wider where there is room, so they + /// read as columns rather than one run-on block hugging the left edge. + /// Widening it also feeds `ViewThatFits`, which will drop to one column if + /// the roomier pair no longer fits. + private var columnGap: CGFloat { labelsFitBesideBar ? 18 : 10 } + /// One column of heard rows. private func heardColumn(_ nodes: [WatchHeardNode]) -> some View { VStack(alignment: .leading, spacing: 2) { @@ -230,43 +269,72 @@ struct MapPage: View { .lineLimit(1) } else if let snapshot { TimelineView(.periodic(from: .now, by: 1)) { context in - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - Capsule() - .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) - .frame( - width: geo.size.width - * (snapshot.phaseRemainingFraction(at: context.date) ?? 0) - ) + if labelsFitBesideBar { + // Big screens have width to spare, so spend it on legibility: phase + // name and remaining time both stand clear of the track, and the bar + // is left as a pure gauge. + HStack(spacing: 6) { + phaseTitle(snapshot) + track(snapshot, at: context.date) + countdown(snapshot, at: context.date) } - // Label rides on the bar rather than beside it: a separate column - // costs width permanently, and the phase title needs the room. - .overlay(alignment: .trailing) { - Group { - if let endsAt = snapshot.phaseEndsAt, endsAt > context.date { - Text(timerInterval: context.date...endsAt, countsDown: true) - .font(.system(size: 11, weight: .bold).monospacedDigit()) - .frame(width: 38, alignment: .trailing) - } else { - Text(snapshot.phaseTitle) - .font(.system(size: 10, weight: .semibold)) - .lineLimit(1) - .truncationMode(.tail) + } else { + // Small screens cannot afford two label columns, so the one label + // that matters rides on the bar. Countdown when a phase is running, + // otherwise its name. + track(snapshot, at: context.date) + .overlay(alignment: .trailing) { + Group { + if snapshot.phaseEndsAt.map({ $0 > context.date }) == true { + countdown(snapshot, at: context.date) + } else { + phaseTitle(snapshot) + } } + // The fill slides under the label, so a shadow keeps it readable + // against both the filled and empty parts of the track. + .shadow(color: .black.opacity(0.7), radius: 1.5) + .padding(.trailing, 6) } - .foregroundStyle(.white) - // The fill slides under the label, so a shadow keeps it readable - // against both the filled and empty parts of the track. - .shadow(color: .black.opacity(0.7), radius: 1.5) - .padding(.trailing, 6) - } } } .frame(height: 15) } } + /// The depleting track. Greedy on purpose — it takes whatever width the + /// labels beside it leave. + private func track(_ snapshot: WatchSnapshot, at date: Date) -> some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + .frame(width: geo.size.width * (snapshot.phaseRemainingFraction(at: date) ?? 0)) + } + } + } + + private func phaseTitle(_ snapshot: WatchSnapshot) -> some View { + Text(snapshot.phaseTitle) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + } + + /// Fixed width because `Text(timerInterval:)` otherwise reserves room for the + /// widest value it might ever show, which would starve the track. + @ViewBuilder + private func countdown(_ snapshot: WatchSnapshot, at date: Date) -> some View { + if let endsAt = snapshot.phaseEndsAt, endsAt > date { + Text(timerInterval: date...endsAt, countsDown: true) + .font(.system(size: 11, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .frame(width: 38, alignment: .trailing) + } + } + /// Shrink the rows for longer path hashes, the same way `RepeaterIdChip` /// does on the phone. A 3-byte zone yields 6-character IDs, which at full /// size would run the box across a 40 mm screen. @@ -280,11 +348,11 @@ struct MapPage: View { private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @ViewBuilder - private var recenterButton: some View { + private func recenterButton(_ proxy: MapProxy) -> some View { if !isFollowing, fix != nil { Button { followSuspendedUntil = nil - recenterIfFollowing(force: true) + recenterIfFollowing(proxy, force: true) } label: { Image(systemName: "location.fill") .font(.system(size: 10)) @@ -297,7 +365,7 @@ struct MapPage: View { // MARK: - Map - private var map: some View { + private func map(_ proxy: MapProxy) -> some View { Map(position: $camera, interactionModes: [.pan, .zoom]) { linkLines pingMarkers @@ -308,6 +376,7 @@ struct MapPage: View { .onMapCameraChange(frequency: .onEnd) { context in noteRenderedRegion(context.region) noteCameraChange(context.region.center) + correctPlacement(proxy) } .ignoresSafeArea(edges: .bottom) } @@ -367,30 +436,59 @@ struct MapPage: View { // MARK: - Camera - private func recenterIfFollowing(force: Bool = false) { + private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { guard force || isFollowing, let fix else { return } - let center = centerPlacing(fix) + let center = centerPlacing(fix, proxy: proxy) programmaticCenter = center withAnimation(.easeInOut(duration: 0.25)) { camera = .region(MKCoordinateRegion(center: center, span: currentSpan)) } } - /// Region centre that puts [fix] in the middle of the band between the top - /// of the display and the top of the panel. + /// Region centre that puts [fix] midway between the top of the display and + /// the top of the panel. /// - /// MapKit centres the region in the whole view, so with a panel covering the - /// lower third the fix would sit low and partly behind it. Shifting the - /// region centre south by half the obscured height lifts the fix by the same - /// amount on screen. - private func centerPlacing(_ fix: CLLocationCoordinate2D) -> CLLocationCoordinate2D { - let shift = currentSpan.latitudeDelta * Double(obscuredFraction) / 2 + /// MapKit centres the region in the map, so with a panel over the lower third + /// the fix would sit low and partly behind it. Rather than predict how far to + /// shift, this asks the map which coordinate is at the target point today and + /// translates the camera by the difference — exact whatever the projection, + /// the zoom, or the latitude. + private func centerPlacing( + _ fix: CLLocationCoordinate2D, + proxy: MapProxy + ) -> CLLocationCoordinate2D { + // Before the first render there is nothing to translate against; centring + // on the fix is the right opening move, and `correctPlacement` lifts it as + // soon as the map reports back. + guard let targetPoint, + let renderedCenter, + let atTarget = proxy.convert(targetPoint, from: .global) + else { return fix } + + // Clamped so a bad conversion — an off-map point, a mid-animation read — + // can never fling the camera somewhere the wearer has to chase. + let lift = (fix.latitude - atTarget.latitude) + .clamped(to: -currentSpan.latitudeDelta...currentSpan.latitudeDelta) return CLLocationCoordinate2D( - latitude: fix.latitude - shift, + latitude: renderedCenter.latitude + lift, longitude: fix.longitude ) } + /// Nudge the camera once the map reports where things really landed. + /// + /// The first placement runs before any render, and a zoom changes the scale + /// underneath us, so placement is a feedback loop rather than a calculation. + /// The deadband is what stops it: each pass lands within a couple of points, + /// the next sees no error worth fixing, and it settles. + private func correctPlacement(_ proxy: MapProxy) { + guard isFollowing, let fix, let targetPoint, + let point = proxy.convert(fix, to: .global) + else { return } + guard abs(point.y - targetPoint.y) > 6 else { return } + recenterIfFollowing(proxy) + } + /// Preserve whatever zoom the wearer picked with the Digital Crown. /// /// The initial span is deliberately wide (~3 km): wardriving is about what @@ -401,25 +499,16 @@ struct MapPage: View { longitudeDelta: 0.03 ) - /// Track the region MapKit actually rendered. - /// - /// Two reasons this matters. MapKit fits a requested span to the view's - /// aspect ratio, so the rendered latitude delta differs from the requested - /// one — computing the panel offset against the request under-shifts the - /// camera. And without this, a Digital Crown zoom would be thrown away on - /// the next follow update. + /// Centre of the region MapKit last rendered — the fixed point every + /// placement is measured against. + @State private var renderedCenter: CLLocationCoordinate2D? + + /// Track the region MapKit actually rendered, so a Digital Crown zoom is not + /// thrown away on the next follow update and so placement has a known + /// starting point. private func noteRenderedRegion(_ region: MKCoordinateRegion) { - let previous = currentSpan.latitudeDelta currentSpan = region.span - - // The first render is what reveals the aspect-corrected span, so re-place - // the fix once against it. This converges: the follow-up request carries - // the rendered span, so the next change reports no material difference. - guard previous > 0 else { return } - let drift = abs(region.span.latitudeDelta - previous) / previous - if drift > 0.05 { - recenterIfFollowing() - } + renderedCenter = region.center } private func noteCameraChange(_ center: CLLocationCoordinate2D) { @@ -454,8 +543,9 @@ struct MapPage: View { try? await Task.sleep(for: .seconds(seconds)) } guard !Task.isCancelled, followSuspendedUntil == deadline else { return } + // Clearing this drives the recentre, via `onChange` where a proxy is in + // scope. followSuspendedUntil = nil - recenterIfFollowing() } } @@ -470,6 +560,12 @@ struct MapPage: View { } } +extension Comparable { + fileprivate func clamped(to limits: ClosedRange) -> Self { + min(max(self, limits.lowerBound), limits.upperBound) + } +} + // MARK: - Markers private struct FixPuck: View { From 25af775b0222e5645eda2f461a506d66600e0588 Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 20:32:45 -0700 Subject: [PATCH 12/71] Put both bar labels back on the track and fit the panel to the display curve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes, implemented by Codex against a spec and verified here on both simulator sizes. The phase title and the countdown both ride the bar again, at opposite ends of one overlay. Splitting them by screen size was wrong: it moved "Listening" permanently to the left of the track on large watches and left the right end of the bar empty, which read as the clock having disappeared. One treatment now serves every size. The panel is narrower and sits 4 pt from the bottom edge. It may descend into the bottom safe area only in exchange for horizontal clearance, because that safe area is what the display's curvature costs: modelling the corner as a circle of radius R, a panel whose bottom edge is g from the edge needs R - sqrt(2Rg - g^2) of inset, plus 4 pt because the reported inset is a lower bound on the real glass. watchOS exposes no corner radius, but its bottom safe-area inset is the clearance a full-width element demands, which is that radius. The 46 mm reports 36 and the 40 mm 19, so the large watch narrows nearly twice as much — the asymmetry that was asked for, arrived at rather than assumed. Top Heard is two columns everywhere now, including 40 mm, with the type size solved from the width actually available rather than picked from a hash-length ladder. Only a six-character zone on the smallest screen falls back to one column, and it now does so at the 9 pt cap instead of inheriting the two-column floor. Measured, in points, with the panel top and the fix both verified stable across frames: 46 mm safeBottom 36 inset 23.5 panel 157.5 wide at y=190 font 9.8 40 mm safeBottom 19 inset 11.3 panel 135.5 wide at y=144 font 7.8 40 mm six-char inset 11.3 panel 135.3 wide at y=115 font 9.0 Three measurement traps cost a build each and are worth remembering. Reading the container's width to compute padding applied to that same container is a feedback loop — the 40 mm reported itself 169 then 173 pt wide on a 162 pt display, and the panel landed 2.3 pt from the edge instead of the 9.3 it had just computed; WKInterfaceDevice.screenBounds is static and cannot feed back. A GeometryReader in the map's background reports no safe area, because the map ignores it. And `.ignoresSafeArea()` on the reader itself reports none either, since it measures its own expanded region — plain is correct here, and the first value is latched so the panel can never perturb the number that positioned it. The corner model still needs confirming on hardware: the simulator renders a flat rectangle and cannot show a clip. --- ios/MeshMapperWatch/MapPage.swift | 175 +++++++++++++++++++----------- 1 file changed, 109 insertions(+), 66 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 4394e4f..064d037 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -1,6 +1,7 @@ import CoreLocation import MapKit import SwiftUI +import WatchKit /// The map, drawn on Apple's basemap. /// @@ -43,7 +44,7 @@ struct MapPage: View { /// The panel's frame in global coordinates, so the camera can keep the fix /// out from behind it. @State private var panelFrame: CGRect = .zero - @State private var viewWidth: 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 @@ -63,18 +64,44 @@ struct MapPage: View { return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } - /// Width the panel's content actually gets: the display minus the overlay's - /// horizontal padding (4 a side) and the panel's own (8 a side). - private var panelContentWidth: CGFloat { max(0, viewWidth - 24) } + private static let panelBottomGap: CGFloat = 4 - /// Whether there is room to set the phase title and the countdown *beside* - /// the bar rather than on top of it. + /// 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. /// - /// Budget: ~52 pt for the longest phase title, ~38 for the countdown, two - /// 6 pt gaps, and ~64 left for the bar so it still reads as a gauge. A 46 mm - /// screen has 184 pt to spend and a 40 mm one has 138, so the two sizes get - /// genuinely different treatments instead of the small one being squeezed. - private var labelsFitBesideBar: Bool { panelContentWidth >= 160 } + /// 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 our chosen bottom gap. That 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 by this equation. + private var curvedPanelHorizontalInset: CGFloat? { + let radius = bottomSafeAreaInset + let gap = Self.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) + } var body: some View { // The proxy is the only reliable way to relate a coordinate to a point on @@ -92,13 +119,13 @@ struct MapPage: View { ZStack { map(proxy) - // One panel in the top-leading corner carrying phase and Top Heard. + // One panel carrying phase and Top Heard. // // Earlier versions floated the countdown in the opposite corner and drew - // into the safe areas to reach the edges. On real hardware both got - // clipped by the display curvature — the simulator renders a flat - // rectangle and never shows it. The safe area is honoured now, and - // merging the two overlays means there is no second corner to lose. + // full-width into the safe areas. On real hardware both got clipped by + // the display curvature — the simulator renders a flat rectangle and + // never shows it. This panel enters only the bottom safe area, and only + // after the measured curvature has bought enough horizontal clearance. VStack(spacing: 0) { HStack { Spacer(minLength: 0) @@ -112,15 +139,24 @@ struct MapPage: View { } ) } - .padding(.horizontal, 4) + .padding(.horizontal, panelHorizontalInset) .padding(.top, 2) + .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : Self.panelBottomGap) + .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) } .background( GeometryReader { geo in Color.clear - .onAppear { viewWidth = geo.size.width } - .onChange(of: geo.size.width) { _, width in viewWidth = width } + .onAppear { latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) } + .onChange(of: geo.safeAreaInsets.bottom) { _, inset in + latchBottomSafeAreaInset(inset) + } } + // Plain is intentional: `.ignoresSafeArea()` makes this reader report + // the insets of its own expanded region, which are zero. The first + // nonzero value is latched while the panel is still in its safe fallback + // placement; later panel geometry depends on it, while the display's + // actual safe area is a device constant that cannot legitimately change. ) .onPreferenceChange(PanelFrameKey.self) { frame in guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } @@ -154,6 +190,11 @@ struct MapPage: View { } } + private func latchBottomSafeAreaInset(_ inset: CGFloat) { + guard bottomSafeAreaInset == 0, inset > 0 else { return } + bottomSafeAreaInset = inset + } + /// Phase and Top Heard in one panel. /// /// Rows are `[type dot] [hex ID] [SNR]`. The hex path hash is the identity, @@ -172,21 +213,21 @@ struct MapPage: View { .foregroundStyle(.white.opacity(0.45)) .frame(maxWidth: .infinity, alignment: .leading) } else { - // Two columns when they fit, one when they don't. A 3-byte zone's - // six-character hashes plus SNR will not fit two columns on a 40 mm - // screen, and truncating the ID is not an option — it is the - // repeater's identity. ViewThatFits picks by measurement rather than - // by a guess about screen size. - ViewThatFits(in: .horizontal) { + // 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) } } } - .frame(maxWidth: .infinity, alignment: .leading) } } .padding(.horizontal, 8) @@ -209,11 +250,10 @@ struct MapPage: View { .opacity(client.isStale ? 0.5 : 1.0) } - /// Gutter between the two heard columns. Wider where there is room, so they - /// read as columns rather than one run-on block hugging the left edge. - /// Widening it also feeds `ViewThatFits`, which will drop to one column if - /// the roomier pair no longer fits. - private var columnGap: CGFloat { labelsFitBesideBar ? 18 : 10 } + /// 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 { @@ -224,8 +264,8 @@ struct MapPage: View { /// `[type dot] [hex ID] [SNR]`, sized to its content. /// - /// Content-sized on purpose: a greedy row always "fits", which would stop - /// `ViewThatFits` from ever rejecting the two-column layout. + /// 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() @@ -269,41 +309,25 @@ struct MapPage: View { .lineLimit(1) } else if let snapshot { TimelineView(.periodic(from: .now, by: 1)) { context in - if labelsFitBesideBar { - // Big screens have width to spare, so spend it on legibility: phase - // name and remaining time both stand clear of the track, and the bar - // is left as a pure gauge. - HStack(spacing: 6) { - phaseTitle(snapshot) - track(snapshot, at: context.date) - countdown(snapshot, at: context.date) - } - } else { - // Small screens cannot afford two label columns, so the one label - // that matters rides on the bar. Countdown when a phase is running, - // otherwise its name. - track(snapshot, at: context.date) - .overlay(alignment: .trailing) { - Group { - if snapshot.phaseEndsAt.map({ $0 > context.date }) == true { - countdown(snapshot, at: context.date) - } else { - phaseTitle(snapshot) - } - } - // The fill slides under the label, so a shadow keeps it readable - // against both the filled and empty parts of the track. - .shadow(color: .black.opacity(0.7), radius: 1.5) - .padding(.trailing, 6) + track(snapshot, at: context.date) + .overlay { + HStack { + phaseTitle(snapshot) + Spacer(minLength: 4) + countdown(snapshot, at: context.date) } - } + // 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) + } } .frame(height: 15) } } - /// The depleting track. Greedy on purpose — it takes whatever width the - /// labels beside it leave. + /// The depleting track. Greedy on purpose: the labels overlay it, leaving the + /// whole panel width available to show phase progress. private func track(_ snapshot: WatchSnapshot, at date: Date) -> some View { GeometryReader { geo in ZStack(alignment: .leading) { @@ -335,16 +359,35 @@ struct MapPage: View { } } - /// Shrink the rows for longer path hashes, the same way `RepeaterIdChip` - /// does on the phone. A 3-byte zone yields 6-character IDs, which at full - /// size would run the box across a 40 mm screen. - private var rowFontSize: CGFloat { + /// Largest size allowed by the existing hash-length ladder. Width fitting + /// may make it smaller, but never larger than the familiar phone treatment. + private var rowFontSizeCap: CGFloat { let widest = heard.map(\.id.count).max() ?? 2 if widest > 4 { return 9 } if widest > 2 { return 10 } return 11 } + /// 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) + } + + /// Seven points is the floor at which two columns remain readable. Falling + /// below it is the one reason to spend the extra height on a single column. + private var twoHeardColumnsFit: Bool { unconstrainedRowFontSize >= 7 } + + private var rowFontSize: CGFloat { + guard twoHeardColumnsFit else { return rowFontSizeCap } + return min(unconstrainedRowFontSize.clamped(to: 7...11), rowFontSizeCap) + } + private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @ViewBuilder From 2fa0aef6d3f11753b7c8520939ddc8cfc393af8b Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 20:38:01 -0700 Subject: [PATCH 13/71] Raise the panel 4 pt without spending the curvature clearance on width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Placement and clearance gaps are now separate constants. The panel sits 8 pt from the bottom edge, but its horizontal inset is still evaluated as if it sat at 4 — otherwise the geometry would hand back roughly 12 pt of width on the 46 mm, undoing the narrowing that was the point. Keeping the lower position as a clearance floor is also the conservative direction for curvature the simulator cannot show. Measured: both insets unchanged at 23.51 and 11.34, both panels 4 pt higher, and the fix recentred with them — 92.75 pt against a 93.0 target on the 46 mm, 69.75 against 70.0 on the 40 mm. Also verified the six-character worst case on the 46 mm, which had only been checked on the 40 mm: two columns at ~8 pt, no overflow. The 40 mm remains the sole size that falls back to one column, at the 9 pt cap. --- ios/MeshMapperWatch/MapPage.swift | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 064d037..08d7e01 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -64,7 +64,13 @@ struct MapPage: View { return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } - private static let panelBottomGap: CGFloat = 4 + /// Placement and clearance gaps deliberately differ. The panel sits higher, + /// but its inset is evaluated as if it still sat four points from the edge; + /// otherwise raising it would spend the recovered curvature room on width. + /// Keeping the lower position as a clearance floor is the conservative + /// direction for hardware curvature the simulator cannot reveal. + private static let panelBottomGap: CGFloat = 8 + private static let curveClearanceGap: CGFloat = 4 /// Panel geometry is about the physical display, not this view's proposal. /// Reading the device avoids a feedback loop where panel padding changes the @@ -77,14 +83,15 @@ struct MapPage: View { /// 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 our chosen bottom gap. That 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 by this equation. + /// the inset at the more conservative of the placement and clearance gaps. + /// 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 by this equation. private var curvedPanelHorizontalInset: CGFloat? { let radius = bottomSafeAreaInset - let gap = Self.panelBottomGap + let gap = min(Self.panelBottomGap, Self.curveClearanceGap) 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 } From f0961c11a5844951cfdfa693ce98f5a0a8653a4e Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 20:48:21 -0700 Subject: [PATCH 14/71] Scale the panel gaps with the corner radius, and stop asserting lapsed phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both gaps now scale with the measured corner radius rather than being fixed. A panel sitting higher needs less horizontal clearance, so height buys width — the trade this deliberately refused last commit, when the ask was narrower-and-higher and only the height part had arrived. The ask now includes the width, because six-character hashes on a 46 mm were down at 8 pt. The 4/19 and 8/19 ratios are calibrated from the 40 mm, the one size confirmed good on hardware, so it reproduces its numbers exactly by construction — inset 11.338, content 123.324, font 7.825, verified unchanged. The 46 mm gains 11 pt of width and sits 15 pt off the bottom, taking six-character IDs from 8.0 to 8.81 pt. Width still binds there rather than the hash-length ladder's 9 pt ceiling. A lapsed deadline no longer claims its phase. The title was never wrong — the phone sends "Listening…" while the RX window runs and "Next ping" while the auto-ping timer does, matching ping_controls — but the watch rendered whatever it last heard forever, so a passed deadline with no newer snapshot left "Listening" asserted over an empty track. That is the state Adam photographed. Titles with a future deadline are unchanged; titles with no deadline at all stay full strength, since "Device disconnected" and "Waiting for GPS" are states rather than countdowns and remain true until replaced; a title whose deadline has passed now dims to 45%, reading as last-known rather than current. The reason this took until now to surface is that SampleSnapshot hardcoded "Listening", so no screenshot ever showed a wait phase. -MeshMapperSamplePhase listen|wait|lapsed fixes that gap. Verified all three render correctly, and that it and the three existing launch arguments are absent from the Release binary. --- ios/MeshMapperWatch/MapPage.swift | 49 +++++++++++++++--------- ios/MeshMapperWatch/SampleSnapshot.swift | 22 +++++++++-- 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 08d7e01..b01787b 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -64,13 +64,21 @@ struct MapPage: View { return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } - /// Placement and clearance gaps deliberately differ. The panel sits higher, - /// but its inset is evaluated as if it still sat four points from the edge; - /// otherwise raising it would spend the recovered curvature room on width. - /// Keeping the lower position as a clearance floor is the conservative - /// direction for hardware curvature the simulator cannot reveal. - private static let panelBottomGap: CGFloat = 8 - private static let curveClearanceGap: CGFloat = 4 + /// Both gaps scale with the estimated corner radius, putting every watch at + /// the same relative positions on its curve. The 4/19 and 8/19 ratios are + /// calibrated from the 40 mm watch Adam signed off: at R=19 they reproduce + /// its current clearance and placement by construction. Changing either + /// constant therefore changes the one hardware size already known-good. + private static let curveClearanceGapRatio: CGFloat = 4.0 / 19.0 + private static let panelBottomGapRatio: CGFloat = 8.0 / 19.0 + + private var curveClearanceGap: CGFloat { + bottomSafeAreaInset * Self.curveClearanceGapRatio + } + + 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 @@ -83,15 +91,14 @@ struct MapPage: View { /// 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 more conservative of the placement and clearance gaps. - /// 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 by this equation. + /// the inset at the scaled clearance 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 by this equation. private var curvedPanelHorizontalInset: CGFloat? { let radius = bottomSafeAreaInset - let gap = min(Self.panelBottomGap, Self.curveClearanceGap) + let gap = curveClearanceGap 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 } @@ -148,7 +155,7 @@ struct MapPage: View { } .padding(.horizontal, panelHorizontalInset) .padding(.top, 2) - .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : Self.panelBottomGap) + .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : panelBottomGap) .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) } .background( @@ -319,7 +326,7 @@ struct MapPage: View { track(snapshot, at: context.date) .overlay { HStack { - phaseTitle(snapshot) + phaseTitle(snapshot, at: context.date) Spacer(minLength: 4) countdown(snapshot, at: context.date) } @@ -346,10 +353,14 @@ struct MapPage: View { } } - private func phaseTitle(_ snapshot: WatchSnapshot) -> some View { - Text(snapshot.phaseTitle) + private func phaseTitle(_ snapshot: WatchSnapshot, at date: Date) -> some View { + // A missing deadline describes a durable state. A passed one describes + // only what the phone last reported, so dimming avoids presenting it as a + // live claim while still preserving the useful last-known phase. + let deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= date } ?? false + return Text(snapshot.phaseTitle) .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.white) + .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) .lineLimit(1) .truncationMode(.tail) } diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 01f42a6..71afec2 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -9,6 +9,10 @@ import Foundation /// /// xcrun simctl launch net.meshmapper.app.watchkitapp -MeshMapperSampleData YES /// +/// Pass `-MeshMapperSamplePhase listen|wait|lapsed` to exercise the live +/// countdown, the between-cycle wait, or a deadline the phone did not replace. +/// Listening is the default so existing capture commands keep their behaviour. +/// /// DEBUG-only, and never reached unless that argument is passed, so it cannot /// leak into a shipping build or mask a real transport failure. enum SampleSnapshot { @@ -24,6 +28,16 @@ enum SampleSnapshot { static func make() -> WatchSnapshot { let now = Date().timeIntervalSince1970 * 1000 + 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 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) @@ -91,11 +105,11 @@ enum SampleSnapshot { wireVersion: MeshMapperWatchWire.version, sessionId: "sample", mode: "Active", - phase: "listening", - phaseTitle: "Listening", + phase: samplePhase.name, + phaseTitle: samplePhase.title, phaseDetail: "Waiting for echoes", - phaseEndsAtMs: now + 42_000, - phaseDurationMs: 60_000, + phaseEndsAtMs: samplePhase.endsAtMs, + phaseDurationMs: samplePhase.durationMs, isConnected: true, zoneCode: "SEA", txCount: 27, From bbd01ca3dc75f2625fedbf57edd308e3741e670d Mon Sep 17 00:00:00 2001 From: agessaman Date: Wed, 12 Aug 2026 23:01:43 -0700 Subject: [PATCH 15/71] Add wrist controls, and make every unavailable control say why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5. The transport and all guards already existed — `_handleWatchCommand` revalidates on arrival and the wire carries `canStartStop`, `canManualPing`, `isSessionActive`, `blockedReason` and the cooldown deadline. What was missing was the surface. Start/stop, and manual ping behind a two-stage confirm: the first tap arms the button for three seconds, the second sends. A wrist bump must not be able to transmit, and disarming needs no round trip. `WatchSessionClient` now tracks the in-flight command so a tap shows work happening, and drops a reply that a newer tap has superseded — otherwise an old answer lands under a fresh action. Silent refreshes stay out of that state; the wearer never asked for them. Three defects found by putting it on both simulator sizes: A cooldown is the only unavailability the phone reports with **no** `blockedReason` — `_buildWatchControls` sets one for "Not connected" and "No GPS fix" only — so the ping button sat dead and unexplained for fifteen seconds, which is precisely what this phase exists to prevent. It now counts down on the button face from the absolute deadline already on the wire, so it stays right without further snapshots. The page title pushed `blockedReason` below the fold on a 40 mm, hiding the one line that explains a disabled button. Dropped: the buttons name themselves. The reason also finished flush against the bottom edge, which is the class of bug that clipped on real hardware twice, so the content now keeps clear of it. Disabled buttons rendered inconsistently — a disabled green `borderedProminent` desaturates to a pale grey that reads as tappable, while the accent-tinted one went dark. Both grey out now. Also fixes a debug affordance that never worked: `MeshMapperInitialPage` was assigned in `onAppear`, and a `.verticalPage` TabView ignores a selection change made that late, so every headless capture silently landed on the map. It is the state's initial value now. Node-list default is its own page, chosen for the room it gives the rows. Sample controls gain `idle|blocked|cooldown` alongside `active`. --- ios/MeshMapperWatch/ContentView.swift | 39 +++-- ios/MeshMapperWatch/ControlsPage.swift | 146 +++++++++++++++++++ ios/MeshMapperWatch/SampleSnapshot.swift | 47 +++++- ios/MeshMapperWatch/WatchSessionClient.swift | 24 ++- ios/MeshMapperWatch/WatchSettings.swift | 7 +- ios/Runner.xcodeproj/project.pbxproj | 4 + 6 files changed, 240 insertions(+), 27 deletions(-) create mode 100644 ios/MeshMapperWatch/ControlsPage.swift diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index 20b9e5a..38ff172 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -2,18 +2,34 @@ import SwiftUI /// Root shell. /// -/// The map is always page one and full-bleed. 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. +/// 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 = 0 + @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 { TabView(selection: $selection) { MapPage().tag(0) + ControlsPage().tag(1) if settings.nodeListPlacement == .page { NavigationStack { @@ -21,19 +37,12 @@ struct ContentView: View { .navigationTitle("Heard") .navigationBarTitleDisplayMode(.inline) } - .tag(1) + .tag(2) } - DebugPage().tag(2) - SettingsPage().tag(3) + DebugPage().tag(3) + SettingsPage().tag(4) } .tabViewStyle(.verticalPage) - .onAppear { - #if DEBUG - // Lets a specific page be captured headlessly for design review. - let requested = UserDefaults.standard.integer(forKey: "MeshMapperInitialPage") - if requested > 0 { selection = requested } - #endif - } } } diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift new file mode 100644 index 0000000..bfc69c6 --- /dev/null +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -0,0 +1,146 @@ +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 + + @State private var pingArmed = false + @State private var disarmPingTask: Task? + + private var controls: WatchControls? { client.snapshot?.controls } + + /// 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. + VStack(spacing: 10) { + startStopButton + manualPingButton + + if let reason = controls?.blockedReason { + Text(reason) + .font(.caption2) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + if let refusal = client.lastRefusal { + Text(refusal) + .font(.caption2) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 8) + .padding(.top, 4) + // 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) + } + // These are fixed pieces of control chrome rather than reading content; + // bounding type preserves the large tap targets on the smallest watch. + .dynamicTypeSize(.small ... .large) + .opacity(client.isStale ? 0.5 : 1.0) + .onChange(of: controls?.canManualPing) { _, canManualPing in + if canManualPing != true { disarmPing() } + } + .onDisappear { disarmPing() } + } + + 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 + + return Button { + client.send(kind) + } label: { + HStack(spacing: 6) { + if isPending { + ProgressView() + .controlSize(.small) + } + Text(isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : "Start")) + .font(.headline) + } + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.borderedProminent) + // Grey when unavailable rather than a desaturated tint: a disabled green + // renders pale enough to read as a live button worth tapping. + .tint(isEnabled ? (isActive ? .red : .green) : .gray) + .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: { + HStack(spacing: 6) { + if isPending { + ProgressView() + .controlSize(.small) + } + if let endsAt = cooldownEndsAt { + // 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 is right + // even if no further snapshot arrives. + Text("Ping in") + .font(.headline) + Text(timerInterval: Date()...endsAt, countsDown: true) + .font(.headline.monospacedDigit()) + .frame(width: 38, alignment: .leading) + } else { + Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) + .font(.headline) + } + } + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.borderedProminent) + .tint(isEnabled ? (pingArmed ? .orange : .accentColor) : .gray) + .disabled(!isEnabled) + } + + /// 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/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 71afec2..230fd94 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -12,6 +12,8 @@ import Foundation /// Pass `-MeshMapperSamplePhase listen|wait|lapsed` to exercise the live /// countdown, the between-cycle wait, or a deadline the phone did not replace. /// Listening is the default so existing capture commands keep their behaviour. +/// Pass `-MeshMapperSampleControls active|idle|blocked|cooldown` to review each +/// controls page state; active is the default. /// /// DEBUG-only, and never reached unless that argument is passed, so it cannot /// leak into a shipping build or mask a real transport failure. @@ -38,6 +40,43 @@ enum SampleSnapshot { samplePhase = ("listening", "Listening…", now + 42_000, 60_000) } + let sampleControls: WatchControls + switch UserDefaults.standard.string(forKey: "MeshMapperSampleControls") { + case "idle": + sampleControls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: false, + manualCooldownEndsAtMs: nil, + blockedReason: nil + ) + case "blocked": + sampleControls = WatchControls( + canStartStop: false, + canManualPing: false, + isSessionActive: 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, + manualCooldownEndsAtMs: now + 12_000, + blockedReason: nil + ) + default: + sampleControls = WatchControls( + canStartStop: true, + canManualPing: true, + isSessionActive: 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) @@ -131,13 +170,7 @@ enum SampleSnapshot { heard: heard, linkedRepeaterIds: ["4E", "77"] ), - controls: WatchControls( - canStartStop: true, - canManualPing: true, - isSessionActive: true, - manualCooldownEndsAtMs: nil, - blockedReason: nil - ), + controls: sampleControls, cue: nil, updatedAtMs: now ) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 03268bc..7eb9820 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -18,6 +18,10 @@ final class WatchSessionClient: NSObject { /// Set when the phone refuses a command, so the wrist can say why. private(set) var lastRefusal: String? + /// Wearer-initiated command awaiting the phone's answer. Automatic refreshes + /// stay out of this state because they have no corresponding wrist action. + private(set) var pendingCommand: WatchCommand.Kind? + /// Set when a payload arrives from a wire version this build predates. private(set) var versionMismatch = false @@ -89,15 +93,27 @@ final class WatchSessionClient: NSObject { return } + if !silent { + lastRefusal = nil + pendingCommand = kind + } + session.sendMessage( [MeshMapperWatchWire.commandKey: dict], replyHandler: { [weak self] reply in NSLog("[WATCH] reply for \(kind.rawValue): \(reply)") Task { @MainActor in + let isCurrent = self?.pendingCommand == kind + if isCurrent { + self?.pendingCommand = nil + } + // A newer tap owns both the spinner and its feedback. Letting an + // older reply rewrite either would put the wrong answer under it. + guard !silent, isCurrent else { return } let accepted = reply["accepted"] as? Bool ?? false if accepted { self?.lastRefusal = nil - } else if !silent { + } else { self?.lastRefusal = reply["reason"] as? String ?? "Refused" } } @@ -105,7 +121,11 @@ final class WatchSessionClient: NSObject { errorHandler: { [weak self] error in NSLog("[WATCH] sendMessage(\(kind.rawValue)) failed: \(error.localizedDescription)") Task { @MainActor in - if !silent { self?.lastRefusal = error.localizedDescription } + let isCurrent = self?.pendingCommand == kind + if isCurrent { + self?.pendingCommand = nil + } + if !silent, isCurrent { self?.lastRefusal = error.localizedDescription } } } ) diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 6c66a1a..e3b9db1 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -18,8 +18,9 @@ final class WatchSettings { /// 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. The default is unsettled until it has - /// been worn — see the plan. + /// 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 @@ -44,7 +45,7 @@ final class WatchSettings { // stored value `bool(forKey:)` returns false, so invert an explicit flag. follow = defaults.object(forKey: Key.follow) as? Bool ?? true nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) - .flatMap(NodeListPlacement.init(rawValue:)) ?? .sheet + .flatMap(NodeListPlacement.init(rawValue:)) ?? .page } /// Apple imagery rather than the standard basemap. Mirrors the iOS app's diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5aa3494..170fd02 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 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 */; }; @@ -105,6 +106,7 @@ 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 = ""; }; @@ -208,6 +210,7 @@ 8523E9AE78A9549CE601F697 /* WatchSettings.swift */, 10A44B241BC20153F36C053D /* SampleSnapshot.swift */, 775B3F6C0618B4BD5644B40B /* NodeListView.swift */, + 1FD851ABE7C5AD5DBCEBDE26 /* ControlsPage.swift */, ); name = MeshMapperWatch; path = MeshMapperWatch; @@ -644,6 +647,7 @@ 54E8A5C4C2081092E8CE145A /* WatchSettings.swift in Sources */, 7288D34A9BC1C140A95C5ED9 /* SampleSnapshot.swift in Sources */, 2E286CDEFC491253FF7615D3 /* NodeListView.swift in Sources */, + 91E86E700B648483F172FAA5 /* ControlsPage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 0385297540cf9e1b92869b8309b2b44c2405dc4b Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 07:41:23 -0700 Subject: [PATCH 16/71] Open the map at 500 m, and remember the wearer's zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three faults in one place, reported from the wrist as "the initial zoom level was very high (multiple states)". The opening view was never the default. `camera` starts `.automatic`, which fits every annotation in the snapshot — continental with repeaters spread wide — and `noteRenderedRegion` then adopted that span as though the wearer had chosen it, so one auto-fit poisoned the zoom for the rest of the session. Rendered spans are now ignored until `programmaticCenter` exists, the same signal that already distinguishes our own camera updates from the wearer's. The default is 500 m rather than 3.3 km. The old comment argued the wide span was deliberate for wardriving; that reasoning was mine and the wrist disagreed, so it is gone rather than left contradicting the code. Zoom now survives relaunch. It was `@State`, so every launch discarded it. It lives in `WatchSettings` with the other preferences, clamped to 0.0005...0.5 on both read and write — persistence turns a stray Crown flick into a permanent state, and the clamp is what stops a remembered preference becoming a trap. Absence is distinguished from zero, because `double(forKey:)` returns 0 for a missing key and would have opened every fresh install at the 55 m minimum. Persisting only on a material change (>1%), since every follow update raises a camera change and writing an identical value would invalidate the observable and re-render the map for nothing. Verified on a fresh simulator container: opens at street level and stores 0.0045, which is proof the `.automatic` span was not adopted — that would have stored a value orders of magnitude larger. --- ios/MeshMapperWatch/MapPage.swift | 33 +++++++++++++++++-------- ios/MeshMapperWatch/WatchSettings.swift | 33 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 10 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index b01787b..d0dd821 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -550,15 +550,15 @@ struct MapPage: View { recenterIfFollowing(proxy) } - /// Preserve whatever zoom the wearer picked with the Digital Crown. - /// - /// The initial span is deliberately wide (~3 km): wardriving is about what - /// is around you, and a tighter default opens with every nearby repeater - /// off-screen. - @State private var currentSpan = MKCoordinateSpan( - latitudeDelta: 0.03, - longitudeDelta: 0.03 - ) + /// MapKit fits longitude to the watch's aspect ratio, so latitude is the one + /// independent zoom value. A fresh install starts at 0.0045 degrees, about + /// 500 m north-south, and later launches reuse the wearer's Crown setting. + private var currentSpan: MKCoordinateSpan { + MKCoordinateSpan( + latitudeDelta: settings.mapLatitudeDelta, + longitudeDelta: settings.mapLatitudeDelta + ) + } /// Centre of the region MapKit last rendered — the fixed point every /// placement is measured against. @@ -568,8 +568,21 @@ struct MapPage: View { /// thrown away on the next follow update and so placement has a known /// starting point. private func noteRenderedRegion(_ region: MKCoordinateRegion) { - currentSpan = region.span renderedCenter = region.center + // Before our first camera update this region belongs to `.automatic`, + // which fits every annotation and can span a continent. It is not a wearer + // choice and must never become the remembered zoom. Once we have driven + // the camera, rendered spans include our requested value and later Digital + // Crown changes, both of which should persist. + guard programmaticCenter != nil else { 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. + let rendered = region.span.latitudeDelta + let stored = settings.mapLatitudeDelta + guard stored > 0, abs(rendered - stored) / stored > 0.01 else { return } + settings.mapLatitudeDelta = rendered } private func noteCameraChange(_ center: CLLocationCoordinate2D) { diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index e3b9db1..9a4f52b 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -12,9 +12,14 @@ final class WatchSettings { static let satellite = "map.satellite" static let showLinks = "map.showLinks" static let follow = "map.follow" + static let mapLatitudeDelta = "map.latitudeDelta" static let nodeListPlacement = "layout.nodeListPlacement" } + /// Roughly 500 m north-south: one degree of latitude is about 111 km. + static let defaultMapLatitudeDelta = 0.0045 + private static let mapLatitudeDeltaLimits = 0.0005...0.5 + /// Where the recently-responded list lives. /// /// Both layouts are built from one view model, so this is a presentation @@ -44,6 +49,13 @@ final class WatchSettings { // 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 + // `double(forKey:)` turns absence into zero, which would silently select + // the minimum zoom. Preserve the distinction so fresh installs get the + // deliberate ~500 m default instead. + mapLatitudeDelta = Self.clampedMapLatitudeDelta( + defaults.object(forKey: Key.mapLatitudeDelta) as? Double + ?? Self.defaultMapLatitudeDelta + ) nodeListPlacement = (defaults.string(forKey: Key.nodeListPlacement)) .flatMap(NodeListPlacement.init(rawValue:)) ?? .page } @@ -64,9 +76,30 @@ final class WatchSettings { 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) + } + } + var nodeListPlacement: NodeListPlacement { didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) } } + + private static func clampedMapLatitudeDelta(_ value: Double) -> Double { + guard value.isFinite else { return defaultMapLatitudeDelta } + return min(max(value, mapLatitudeDeltaLimits.lowerBound), mapLatitudeDeltaLimits.upperBound) + } } extension Color { From 76d8a3bb24929724d92aea4376af1935f6297fb2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 08:05:20 -0700 Subject: [PATCH 17/71] Cut to the fix on launch, mirror the app's ping markers and ping gate, add the icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four wrist reports. **The map flew across the north Pacific on launch.** Two faults stacked. `recenterIfFollowing` always animated, so the first placement was a 0.25 s flight from `.automatic`'s arbitrary opening position to the fix, dragging tile loads the whole way. The first placement is now a cut; later ones still animate, which is right for small follow nudges. Underneath that, `.automatic` settles *after* our first request, centred on the annotation cloud — measured 372 m from the fix — and `noteCameraChange` read that disagreement as the wearer panning. It suspended following for eight seconds and overwrote its own expectation, so our region landing then looked like a *second* pan: [pan] SUSPEND dist=371.9 center=47.611891,-122.323025 expected=47.6122,-122.3181 [pan] SUSPEND dist=371.9 center=47.6122,-122.3181 expected=47.611891,-122.323025 Nothing counts as a pan now until MapKit has confirmed a centre we asked for. Zero suspensions at launch, down from two, and the fix lands 1.9 pt from target on 46 mm and 0.25 pt on 40 mm. This also explains a transient recenter button I dismissed as mid-animation two days ago, and it means the placement regression in `a846153` was mine to catch and I did not — I said the puck looked centred without measuring it. It was 57 pt low. **Manual ping was offered where the phone would refuse it.** The watch gate was `isConnected && hasGpsLock` plus cooldown; the app's own Send Ping button requires twelve conditions. `_buildWatchControls` now mirrors that set exactly, through the same `manualPingValidation` getter the widget uses, rather than a second implementation of the policy. `blockedReason` gains the app's own words for the two states it names, "Offline Mode" and "Passive Only". **Ping markers are circles.** They were squares under a comment claiming they matched the iOS map, which is how it survived — `_CoverageMarkerPainter` draws a filled circle with a white border. Mirrored at wrist scale, minus the shadow, which would cost a blur each for up to sixty markers. **The watch had no icon** because its `AppIcon.appiconset` held a `Contents.json` expecting an image and no image. Copied the 1024 pt iOS icon in; `AppIcon` now compiles into the watch's `Assets.car`, which previously carried only `AccentColor`. --- .../AppIcon.appiconset/Contents.json | 1 + .../Icon-Watch-1024x1024.png | Bin 0 -> 104491 bytes ios/MeshMapperWatch/MapPage.swift | 46 +++++++++++++++--- lib/providers/app_state_provider.dart | 33 ++++++++++++- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Icon-Watch-1024x1024.png diff --git a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json index 49c81cd..88819d5 100644 --- a/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/MeshMapperWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "Icon-Watch-1024x1024.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" 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 0000000000000000000000000000000000000000..ee5cc0cd157e54d755ef5992f3dc074fd3806e13 GIT binary patch literal 104491 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≺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 Lteqv>y-xoF Self.panTolerance else { + + let drift = distance(center, expected) + + // `.automatic` settles *after* our first request, centred on the annotation + // cloud — measured 372 m from the fix — and that disagreement is not a pan. + // Reading it as one suspended following for eight seconds at every launch, + // and overwrote the expectation, so our own region landing then looked like + // a second pan. Nothing counts as a pan until MapKit has confirmed a centre + // we actually asked for. + guard hasConfirmedRequestedCenter else { + if drift <= Self.panTolerance { hasConfirmedRequestedCenter = true } + return + } + + guard drift > Self.panTolerance else { // Our own follow update landing. return } diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index f1cefac..1833f41 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1354,18 +1354,49 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchControls _buildWatchControls() { final cooldownMs = _manualPingCooldownTimer.remainingMs; + // Keep this gate identical to the app's Send Ping button. The watch only + // reflects the phone's latest answer; command handling revalidates again. + 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 canManualPing = canPingManual && + !isAutoStarting && + !isTxModeActive && + !isTargetedRunning && + !cooldownActive && + !manualCooldownActive && + !txBlockedByOffline && + !txNotAllowed && + !rxWindowActive && + !pingSending && + !discoveryWindowActive && + !pendingDisable; + final String? blockedReason; if (!isConnected) { blockedReason = 'Not connected'; } else if (!hasGpsLock) { blockedReason = 'No GPS fix'; + } else if (txBlockedByOffline) { + blockedReason = 'Offline Mode'; + } else if (txNotAllowed) { + blockedReason = 'Passive Only'; } else { blockedReason = null; } return WatchControls( canStartStop: isConnected, - canManualPing: canPing && cooldownMs <= 0, + canManualPing: canManualPing, isSessionActive: _autoPingEnabled, manualCooldownEndsAt: cooldownMs > 0 ? DateTime.now().add(Duration(milliseconds: cooldownMs)) From ea78b4f801f96bcb9eba3d85f7c76c3d36e08614 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 08:31:37 -0700 Subject: [PATCH 18/71] Single-source the manual-ping gate, and stop labels and errors misreporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three wrist reports, plus two defects found verifying them. **The command handler's guards were weaker than the button's.** Its manualPing case checked only connection, GPS and cooldown, then called sendPing — while `_buildWatchControls` mirrored the app's twelve conditions. So a wrist tap could reach the radio in a zone where TX is not permitted, with nothing but sendPing's own checks between. That contradicts the contract stated a few lines above it: every guard is re-evaluated because a stale payload must never cause a transmit. The condition set now lives once, in `_manualPingAvailability`, returning availability and reason together. One caller decides what the wrist offers; the other decides whether the radio transmits. Duplicating that policy is exactly how the two drifted apart. **"Stopping…" jumped to the right edge.** A SwiftUI ProgressView takes the horizontal slack a stack offers it, so the spinner shoved the label aside the moment a command went pending. Spinners are leading overlays now, outside the layout that centres the label — feedback should not move the thing under the wearer's thumb. **A dead transport error sat under the button.** "Payload could not be delivered." is WatchConnectivity's wording and it stayed on screen indefinitely, reading as current state rather than one past action. Refusals expire after six seconds, and the delivery-failure and unreachable codes now say "iPhone didn't respond, try again". Refusals from the phone stay verbatim — those are already written for people, and rewording them would put the watch's guess above the phone's statement. No retry: a send whose delivery is uncertain must not be repeated, or a ping goes out twice. Two defects caught in review, neither reported: The cooldown label put two Text views in a `Group`, which applies each modifier to every child — so `maxWidth: .infinity` went to the words and the timer separately and threw them to opposite ends of the button. Verified on screen before fixing. It is one HStack now. The reason ladder fell through to "Another operation is in progress" even when the ping *was* allowed, which would have printed that under two working buttons. Reason is nil unless a refusal is actually happening. --- ios/MeshMapperWatch/ControlsPage.swift | 71 +++++++++++++------- ios/MeshMapperWatch/WatchSessionClient.swift | 54 +++++++++++++-- lib/providers/app_state_provider.dart | 59 +++++++++++----- 3 files changed, 137 insertions(+), 47 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index bfc69c6..13735d3 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -69,15 +69,20 @@ struct ControlsPage: View { return Button { client.send(kind) } label: { - HStack(spacing: 6) { - if isPending { - ProgressView() - .controlSize(.small) + Text(isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : "Start")) + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 44) + // 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) + } } - Text(isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : "Start")) - .font(.headline) - } - .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.borderedProminent) // Grey when unavailable rather than a desaturated tint: a disabled green @@ -98,33 +103,51 @@ struct ControlsPage: View { armPing() } } label: { - HStack(spacing: 6) { + pingLabel(isPending: isPending) + .frame(maxWidth: .infinity, minHeight: 44) + // 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) - } - if let endsAt = cooldownEndsAt { - // 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 is right - // even if no further snapshot arrives. - Text("Ping in") - .font(.headline) - Text(timerInterval: Date()...endsAt, countsDown: true) - .font(.headline.monospacedDigit()) - .frame(width: 38, alignment: .leading) - } else { - Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) - .font(.headline) + .frame(width: 16, height: 16) + .padding(.leading, 6) } } - .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.borderedProminent) .tint(isEnabled ? (pingArmed ? .orange : .accentColor) : .gray) .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(.headline) + } else { + Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) + .font(.headline) + } + } + /// 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() { diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 7eb9820..41723b4 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -18,6 +18,11 @@ final class WatchSessionClient: NSObject { /// Set when the phone refuses a command, so the wrist can say why. private(set) var lastRefusal: String? + /// 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? + /// Wearer-initiated command awaiting the phone's answer. Automatic refreshes /// stay out of this state because they have no corresponding wrist action. private(set) var pendingCommand: WatchCommand.Kind? @@ -81,7 +86,7 @@ final class WatchSessionClient: NSObject { /// attempted unconditionally and `errorHandler` is the source of truth. func send(_ kind: WatchCommand.Kind, silent: Bool = false) { guard let session, session.activationState == .activated else { - if !silent { lastRefusal = "Not connected to iPhone" } + if !silent { setLastRefusal("Not connected to iPhone") } return } @@ -89,12 +94,12 @@ final class WatchSessionClient: NSObject { guard let data = try? MeshMapperWatchWire.encoder.encode(command), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - if !silent { lastRefusal = "Could not encode command" } + if !silent { setLastRefusal("Could not encode command") } return } if !silent { - lastRefusal = nil + setLastRefusal(nil) pendingCommand = kind } @@ -112,9 +117,12 @@ final class WatchSessionClient: NSObject { guard !silent, isCurrent else { return } let accepted = reply["accepted"] as? Bool ?? false if accepted { - self?.lastRefusal = nil + self?.setLastRefusal(nil) } else { - self?.lastRefusal = reply["reason"] as? String ?? "Refused" + // The phone owns the policy and already phrases its refusals for + // people; preserving that text avoids replacing fact with a watch + // side guess about why the command was rejected. + self?.setLastRefusal(reply["reason"] as? String ?? "Refused") } } }, @@ -125,12 +133,46 @@ final class WatchSessionClient: NSObject { if isCurrent { self?.pendingCommand = nil } - if !silent, isCurrent { self?.lastRefusal = error.localizedDescription } + if !silent, isCurrent { + self?.setLastRefusal(Self.refusalMessage(for: error)) + } } } ) } + private func setLastRefusal(_ refusal: String?) { + refusalExpiryTask?.cancel() + refusalExpiryTask = nil + lastRefusal = refusal + + 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?.refusalExpiryTask = nil + } + } + + private static func refusalMessage(for error: Error) -> String { + let nsError = error as NSError + guard nsError.domain == WCErrorDomain, + let code = WCError.Code(rawValue: nsError.code) + else { + return error.localizedDescription + } + + switch code { + case .deliveryFailed, .notReachable: + // Delivery is uncertain in both cases. Give the wearer one useful next + // step, but never retry automatically: a duplicate could transmit. + return "iPhone didn't respond, try again" + default: + return error.localizedDescription + } + } + // MARK: - Ingest private func ingest(context: [String: Any]) { diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 1833f41..7d521a5 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1352,10 +1352,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return resolved; } - WatchControls _buildWatchControls() { - final cooldownMs = _manualPingCooldownTimer.remainingMs; - // Keep this gate identical to the app's Send Ping button. The watch only - // reflects the phone's latest answer; command handling revalidates again. + ({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; @@ -1368,7 +1368,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final pingSending = isPingSending; final discoveryWindowActive = discoveryWindowTimer.isRunning; final pendingDisable = isPendingDisable; - final canManualPing = canPingManual && + final allowed = canPingManual && !isAutoStarting && !isTxModeActive && !isTargetedRunning && @@ -1381,27 +1381,51 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { !discoveryWindowActive && !pendingDisable; - final String? blockedReason; - if (!isConnected) { - blockedReason = 'Not connected'; + // 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) { - blockedReason = 'No GPS fix'; + reason = 'No GPS fix'; } else if (txBlockedByOffline) { - blockedReason = 'Offline Mode'; + reason = 'Offline Mode'; } else if (txNotAllowed) { - blockedReason = 'Passive Only'; + reason = 'Passive Only'; + } else if (manualPingValidation == PingValidation.manualCooldownActive || + cooldownActive || + manualCooldownActive || + rxWindowActive || + discoveryWindowActive) { + reason = 'Cooling down'; + } else if (!canPingManual) { + reason = manualPingValidation.message; } else { - blockedReason = null; + reason = 'Another operation is in progress'; } + return (allowed: allowed, reason: reason); + } + + WatchControls _buildWatchControls() { + final cooldownMs = _manualPingCooldownTimer.remainingMs; + final manualPing = _manualPingAvailability; + return WatchControls( canStartStop: isConnected, - canManualPing: canManualPing, + canManualPing: manualPing.allowed, isSessionActive: _autoPingEnabled, manualCooldownEndsAt: cooldownMs > 0 ? DateTime.now().add(Duration(milliseconds: cooldownMs)) : null, - blockedReason: blockedReason, + // 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: + manualPing.reason == 'Cooling down' ? null : manualPing.reason, ); } @@ -1437,9 +1461,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; case WatchCommandKind.manualPing: - if (!isConnected) return 'Not connected'; - if (!hasGpsLock) return 'No GPS fix'; - if (_manualPingCooldownTimer.remainingMs > 0) return 'Cooling down'; + final availability = _manualPingAvailability; + if (!availability.allowed) { + return availability.reason ?? 'Ping unavailable'; + } final sent = await sendPing(); return sent ? null : 'Ping failed'; } From b82b409a01196c304ee43ad4c94ad39271746c3a Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 08:51:42 -0700 Subject: [PATCH 19/71] Acknowledge admission, not completion, and give async failures a channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start and stop worked from the wrist but showed "iPhone didn't respond, try again" every single time. The command was fine; the acknowledgement was late. `relayCommand` replies only when Dart's future resolves, and `_handleWatchCommand` awaited the entire action — `startSession` awaits `toggleAutoPing`, which makes an API session check and drives BLE. That outruns WatchConnectivity's reply window, so the watch's error handler fired on every success. Guards still run on arrival, unchanged: admission is decided synchronously and refusals still travel in the reply. What changed is that the action itself is no longer awaited before replying. Outcomes were never the reply's job anyway — `isSessionActive`, the phase and the ping colour all reach the wrist through snapshots. That leaves failures that only appear later, and manual ping is the case in point: it is refused inside `_checkSessionBeforeAction`, a *server* call, so no local gate can predict it and the wrist got a bare "Ping failed". `WatchHapticCue` already existed for events of this class, so it gains an optional message; a failed action emits a unique-ID failure cue and schedules a snapshot. The watch shows it through the same expiring path as a refusal — one presentation for "the phone says something went wrong", not two. Cue IDs are deduped against a bounded cache because immediate messages and application context can deliver the same cue in either order. `_checkSessionBeforeAction` had `result.reason` and `result.message` and discarded both. It now keeps them, so a refused ping says why, and `zone_full` reuses the existing "Passive Only" wording rather than inventing a third phrasing for one condition. Wire version deliberately unchanged: the cue field is optional and both sides ship in the same app. Unverified end to end — reproducing it needs a phone doing real BLE work, which the simulator cannot do. The reasoning and the gates are sound; the wrist is the proof. --- ios/MeshMapperWatch/WatchSessionClient.swift | 29 ++++++- ios/Runner/WatchSessionManager.swift | 5 +- ios/Shared/MeshMapperWatchPayload.swift | 3 + lib/providers/app_state_provider.dart | 90 ++++++++++++++++++-- lib/services/watch/watch_bridge_service.dart | 16 +++- lib/services/watch/watch_models.dart | 16 +++- 6 files changed, 138 insertions(+), 21 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 41723b4..8d5653b 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -15,7 +15,8 @@ final class WatchSessionClient: NSObject { /// When the last snapshot arrived — drives the stale badge. private(set) var receivedAt: Date? - /// Set when the phone refuses a command, so the wrist can say why. + /// 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? /// A refusal explains one completed tap, not the current transport state. @@ -23,6 +24,11 @@ final class WatchSessionClient: NSObject { /// 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 the phone's answer. Automatic refreshes /// stay out of this state because they have no corresponding wrist action. private(set) var pendingCommand: WatchCommand.Kind? @@ -116,14 +122,15 @@ final class WatchSessionClient: NSObject { // older reply rewrite either would put the wrong answer under it. guard !silent, isCurrent else { return } let accepted = reply["accepted"] as? Bool ?? false - if accepted { - self?.setLastRefusal(nil) - } else { + if !accepted { // The phone owns the policy and already phrases its refusals for // people; preserving that text avoids replacing fact with a watch // side guess about why the command was rejected. self?.setLastRefusal(reply["reason"] as? String ?? "Refused") } + // Admission is not completion. The dispatch already cleared older + // text, and clearing again here could erase a fast failure cue that + // crossed this acknowledgement on WatchConnectivity's other path. } }, errorHandler: { [weak self] error in @@ -197,6 +204,20 @@ final class WatchSessionClient: NSObject { self.versionMismatch = false self.snapshot = decoded self.receivedAt = Date() + + if let cue = decoded.cue, + self.presentedCueIDs.insert(cue.id).inserted + { + self.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 self.presentedCueIDOrder.count > 64 { + self.presentedCueIDs.remove(self.presentedCueIDOrder.removeFirst()) + } + if let message = cue.message, !message.isEmpty { + self.setLastRefusal(message) + } + } } } } diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index f50cf5e..48abd68 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -149,8 +149,9 @@ final class WatchSessionManager: NSObject { // MARK: - watch → Flutter - /// Relays a command to Dart and returns the ack. Dart owns the decision; - /// this side never evaluates whether a transmit is legal. + /// 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") diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 5b6ed37..f0d5092 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -136,6 +136,9 @@ struct WatchHapticCue: Codable, Hashable { let id: String /// "success" | "failure" | "notification" let kind: String + /// 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? } // MARK: - Snapshot diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 7d521a5..c7e34bc 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -138,6 +138,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// Last position sent to the watch, held until the fix moves far enough to /// be worth an update. See [_resolveWatchPosition]. WatchPosition? _lastWatchPosition; + 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; @@ -1245,6 +1251,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { geo: _buildWatchGeo(now), controls: _buildWatchControls(), pingColor: _resolveWatchPingColor(), + cue: _watchCue, phaseDurationMs: _phaseDurationMsFor(phase.endsAt), updatedAt: now, ); @@ -1436,12 +1443,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchGeoBuilder.pingColor('tx', latest.heardRepeaters.isNotEmpty); } - /// Applies an intent from the wrist. + /// 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. - Future _handleWatchCommand(WatchCommandKind kind) async { + /// + /// 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(WatchCommandKind kind) { if (_isDisposed) return 'App closing'; switch (kind) { @@ -1452,12 +1464,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { case WatchCommandKind.startSession: if (!isConnected) return 'Not connected'; if (_autoPingEnabled) return null; // Already running. - final started = await toggleAutoPing(_autoMode); - return started ? null : 'Could not start'; + unawaited(_runWatchStartSession()); + return null; case WatchCommandKind.stopSession: if (!_autoPingEnabled) return null; // Already stopped. - await toggleAutoPing(_autoMode); + unawaited(_runWatchStopSession()); return null; case WatchCommandKind.manualPing: @@ -1465,11 +1477,57 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (!availability.allowed) { return availability.reason ?? 'Ping unavailable'; } - final sent = await sendPing(); - return sent ? null : 'Ping failed'; + unawaited(_runWatchManualPing()); + return null; } } + Future _runWatchStartSession() async { + _lastSessionCheckFailureReason = null; + try { + final started = await toggleAutoPing(_autoMode); + if (!started) { + _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Could not start'); + } + } catch (error) { + debugError('[WATCH] startSession failed after admission: $error'); + _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Could not start'); + } + } + + Future _runWatchStopSession() async { + try { + final stopped = await toggleAutoPing(_autoMode); + if (!stopped) _emitWatchFailure('Could not stop'); + } catch (error) { + debugError('[WATCH] stopSession failed after admission: $error'); + _emitWatchFailure('Could not stop'); + } + } + + 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', + message: message, + ); + _scheduleWatchSync(immediate: true); + } + LiveActivitySnapshot? _buildLiveActivitySnapshot() { final sessionId = _liveActivitySessionId; if (!_liveActivitySessionActive || sessionId == null) { @@ -4886,6 +4944,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, @@ -4893,6 +4952,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 @@ -4901,6 +4964,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; diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 65b9594..12e5aa3 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -9,9 +9,11 @@ import 'watch_models.dart'; typedef WatchSnapshotBuilder = WatchSnapshot? Function(); -/// Handles a command from the wrist. Returns null when accepted, or a reason -/// string when refused — the reason is shown on the watch. -typedef WatchCommandHandler = Future Function(WatchCommandKind kind); +/// Decides whether a wrist command may begin. Returns null when admitted, or a +/// reason when refused; admitted work continues independently of this reply. +/// Production handlers must decide synchronously; FutureOr keeps existing +/// bridge fakes source-compatible without putting the real path behind a wait. +typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// @@ -85,7 +87,13 @@ class WatchBridgeService { _rememberCommandId(id); try { - final refusal = await handler(kind); + // This is admission, not completion. Keeping the handler synchronous is + // what makes the MethodChannel response fit inside WatchConnectivity's + // short reply window; the admitted action reports its later outcome via + // normal snapshots and one-shot cues. + final admission = handler(kind); + final refusal = + admission is Future ? await admission : admission; // A refused command may legitimately be retried once conditions change. if (refusal != null) _handledCommandIds.remove(id); return {'id': id, 'accepted': refusal == null, 'reason': refusal}; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index d5115bf..4de1700 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -251,14 +251,23 @@ class WatchControls { /// Carries an [id] so the watch fires exactly once: diffing state would /// double-fire on redelivery, which WatchConnectivity does routinely. class WatchHapticCue { - const WatchHapticCue({required this.id, required this.kind}); + const WatchHapticCue({required this.id, required this.kind, this.message}); final String id; /// 'success' | 'failure' | 'notification' final String kind; - Map toMap() => {'id': id, 'kind': kind}; + /// Human-readable detail for an event whose outcome arrived after command + /// admission. This additive field is optional, so v2 remains decodable; no + /// wire bump is needed while the matched phone and watch targets ship it. + final String? message; + + Map toMap() => { + 'id': id, + 'kind': kind, + 'message': message, + }; } /// The complete state the watch renders. @@ -295,8 +304,7 @@ class WatchSnapshot { 'phase': core.phase.wireValue, 'phaseTitle': core.phaseTitle, 'phaseDetail': core.phaseDetail, - 'phaseEndsAtMs': - core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), + 'phaseEndsAtMs': core.phaseEndsAt?.millisecondsSinceEpoch.toDouble(), 'phaseDurationMs': phaseDurationMs, 'isConnected': core.isConnected, 'zoneCode': core.zoneCode, From b460b98e16344130aa116e5d97cd74d3cb296f6f Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 09:09:20 -0700 Subject: [PATCH 20/71] Send wrist commands over a queued transport, and expire them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start and stop kept reporting "iPhone didn't respond, try again" while working. A device console capture ended the guessing: [WATCH] sendMessage(requestSnapshot) failed: ...device is not reachable. [WATCH] sendMessage(startSession) failed: Payload could not be delivered. `sendMessage` needs the counterpart app reachable, which for the phone means roughly foreground — not the normal case when someone taps their watch. WatchConnectivity delivered the payload and the phone acted on it, but the reply could not return, so the watch reported `deliveryFailed` after every success. The previous fix, replying on admission rather than completion, was aimed at reply latency. Latency was never the cause. Both of my diagnoses came from the symptom; only the logged error settled it. Commands and refresh requests now go by `transferUserInfo`: queued, survives unreachability, wakes the counterpart, and has no reply to fail. That is affordable only because outcomes and refusals already return as snapshots and failure cues. `requestSnapshot` gains the most — it used to fail outright with "not reachable", exactly when a refresh is most wanted. Deliberately no opportunistic `sendMessage` and no retry on failure: `deliveryFailed` is reported for payloads the phone *did* process, so a fallback resend would transmit twice. **Queued commands must expire.** A transfer can arrive whenever the phone next becomes reachable, and a ping that fires minutes late is attributed to where the vehicle now is rather than where it was. Commands carry `issuedAtMs`; anything older than 30 s is refused before reaching `_handleWatchCommand`. The ID is remembered first, so redelivery cannot retry it later. `requestSnapshot` is exempt — a late refresh is harmless — and a missing timestamp is still accepted, for watches running the older build. `pendingCommand` was cleared by the reply that no longer exists, so it now clears on the next snapshot with a 10 s backstop; a spinner that never stops is worse than none. `WatchCommandAck` is removed rather than left describing a protocol we no longer speak. --- ios/MeshMapperWatch/WatchSessionClient.swift | 101 +++++++------------ ios/Runner/WatchSessionManager.swift | 12 +++ ios/Shared/MeshMapperWatchPayload.swift | 12 +-- lib/providers/app_state_provider.dart | 5 +- lib/services/watch/watch_bridge_service.dart | 38 ++++++- 5 files changed, 91 insertions(+), 77 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 8d5653b..f5e5473 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -29,9 +29,11 @@ final class WatchSessionClient: NSObject { private var presentedCueIDs = Set() private var presentedCueIDOrder = [String]() - /// Wearer-initiated command awaiting the phone's answer. Automatic refreshes - /// stay out of this state because they have no corresponding wrist action. + /// 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 @@ -84,19 +86,23 @@ final class WatchSessionClient: NSObject { /// - Parameter silent: suppress the refusal banner. Used for the automatic /// refresh, which the wearer never asked for and shouldn't see fail. - /// Sends without pre-checking `isReachable`. + /// Queues without pre-checking `isReachable`. /// - /// That flag lags reality — during testing the simulator reported - /// unreachable while messages were still being delivered a second or two - /// later. Gating on it turns a stale flag into a refused tap, so the send is - /// attempted unconditionally and `errorHandler` is the source of truth. + /// 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, silent: Bool = false) { guard let session, session.activationState == .activated else { if !silent { setLastRefusal("Not connected to iPhone") } return } - let command = WatchCommand(kind: kind, id: UUID().uuidString) + let command = WatchCommand( + kind: kind, + 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 { @@ -106,46 +112,27 @@ final class WatchSessionClient: NSObject { if !silent { setLastRefusal(nil) - pendingCommand = kind + beginPending(kind) } - session.sendMessage( - [MeshMapperWatchWire.commandKey: dict], - replyHandler: { [weak self] reply in - NSLog("[WATCH] reply for \(kind.rawValue): \(reply)") - Task { @MainActor in - let isCurrent = self?.pendingCommand == kind - if isCurrent { - self?.pendingCommand = nil - } - // A newer tap owns both the spinner and its feedback. Letting an - // older reply rewrite either would put the wrong answer under it. - guard !silent, isCurrent else { return } - let accepted = reply["accepted"] as? Bool ?? false - if !accepted { - // The phone owns the policy and already phrases its refusals for - // people; preserving that text avoids replacing fact with a watch - // side guess about why the command was rejected. - self?.setLastRefusal(reply["reason"] as? String ?? "Refused") - } - // Admission is not completion. The dispatch already cleared older - // text, and clearing again here could erase a fast failure cue that - // crossed this acknowledgement on WatchConnectivity's other path. - } - }, - errorHandler: { [weak self] error in - NSLog("[WATCH] sendMessage(\(kind.rawValue)) failed: \(error.localizedDescription)") - Task { @MainActor in - let isCurrent = self?.pendingCommand == kind - if isCurrent { - self?.pendingCommand = nil - } - if !silent, isCurrent { - self?.setLastRefusal(Self.refusalMessage(for: error)) - } - } - } - ) + session.transferUserInfo([MeshMapperWatchWire.commandKey: dict]) + } + + 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 } private func setLastRefusal(_ refusal: String?) { @@ -162,24 +149,6 @@ final class WatchSessionClient: NSObject { } } - private static func refusalMessage(for error: Error) -> String { - let nsError = error as NSError - guard nsError.domain == WCErrorDomain, - let code = WCError.Code(rawValue: nsError.code) - else { - return error.localizedDescription - } - - switch code { - case .deliveryFailed, .notReachable: - // Delivery is uncertain in both cases. Give the wearer one useful next - // step, but never retry automatically: a duplicate could transmit. - return "iPhone didn't respond, try again" - default: - return error.localizedDescription - } - } - // MARK: - Ingest private func ingest(context: [String: Any]) { @@ -204,6 +173,10 @@ final class WatchSessionClient: NSObject { self.versionMismatch = false self.snapshot = decoded self.receivedAt = Date() + // 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. + self.clearPendingCommand() if let cue = decoded.cue, self.presentedCueIDs.insert(cue.id).inserted diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index 48abd68..2cf0a80 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -203,6 +203,16 @@ extension WatchSessionManager: WCSessionDelegate { lastContextData = nil } + 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], @@ -216,6 +226,8 @@ extension WatchSessionManager: WCSessionDelegate { 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))") diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index f0d5092..04a1e7e 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -212,15 +212,13 @@ struct WatchCommand: Codable, Hashable { let kind: Kind /// 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 } -/// The phone's answer to a command. -struct WatchCommandAck: Codable, Hashable { - let id: String - let accepted: Bool - /// Why it was refused, for display on the wrist. - let reason: String? -} +// There is deliberately no acknowledgement model: queued commands have no +// reply channel; state snapshots and failure cues carry every outcome. // MARK: - Coding helpers diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index c7e34bc..06e08db 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1931,7 +1931,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _timerListenable.addListener(_handleLiveActivityTimerChange); } if (_watchBridge.isSupportedPlatform) { - _watchBridge.attachCommandHandler(_handleWatchCommand); + _watchBridge.attachCommandHandler( + _handleWatchCommand, + onRefusal: _emitWatchFailure, + ); } // Initialize debug logging (enabled by default, respects user preference) diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 12e5aa3..8ed9481 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -14,6 +14,7 @@ typedef WatchSnapshotBuilder = WatchSnapshot? Function(); /// Production handlers must decide synchronously; FutureOr keeps existing /// bridge fakes source-compatible without putting the real path behind a wait. typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); +typedef WatchCommandRefusalHandler = void Function(String reason); /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// @@ -30,12 +31,14 @@ class WatchBridgeService { static const Duration _debounceDelay = Duration(milliseconds: 200); static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + static const Duration _maximumCommandAge = Duration(seconds: 30); final MethodChannel _channel; Timer? _scheduledUpdate; WatchSnapshotBuilder? _pendingSnapshotBuilder; WatchCommandHandler? _commandHandler; + WatchCommandRefusalHandler? _commandRefusalHandler; String? _lastPayload; String? _lastUrgencyKey; @@ -51,8 +54,12 @@ class WatchBridgeService { !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; /// Wire up the inbound command path. Safe to call more than once. - void attachCommandHandler(WatchCommandHandler handler) { + void attachCommandHandler( + WatchCommandHandler handler, { + WatchCommandRefusalHandler? onRefusal, + }) { _commandHandler = handler; + _commandRefusalHandler = onRefusal; if (!isSupportedPlatform) return; _channel.setMethodCallHandler(_handleNativeCall); } @@ -86,6 +93,20 @@ class WatchBridgeService { _rememberCommandId(id); + final rawIssuedAtMs = args['issuedAtMs']; + final issuedAtMs = rawIssuedAtMs is num ? rawIssuedAtMs.toDouble() : null; + if (kind != WatchCommandKind.requestSnapshot && issuedAtMs != null) { + final ageMs = DateTime.now().millisecondsSinceEpoch - issuedAtMs; + if (ageMs > _maximumCommandAge.inMilliseconds) { + const reason = 'Took too long to reach iPhone'; + // This window is about correctness, not queue housekeeping: executing + // a transmit after the vehicle has moved attributes it to the wrong + // place. Missing timestamps remain accepted for older watch builds. + _commandRefusalHandler?.call(reason); + return {'id': id, 'accepted': false, 'reason': reason}; + } + } + try { // This is admission, not completion. Keeping the handler synchronous is // what makes the MethodChannel response fit inside WatchConnectivity's @@ -94,13 +115,19 @@ class WatchBridgeService { final admission = handler(kind); final refusal = admission is Future ? await admission : admission; - // A refused command may legitimately be retried once conditions change. - if (refusal != null) _handledCommandIds.remove(id); + if (refusal != null) { + _commandRefusalHandler?.call(refusal); + // Reply-capable legacy watches could retry an admission refusal with + // the same ID. Queued commands must stay remembered: redelivery after + // conditions change must never turn yesterday's tap into a transmit. + if (issuedAtMs == null) _handledCommandIds.remove(id); + } return {'id': id, 'accepted': refusal == null, 'reason': refusal}; } catch (error) { - _handledCommandIds.remove(id); debugError('[WATCH] Command $rawKind failed: $error'); - return {'id': id, 'accepted': false, 'reason': 'Command failed'}; + const reason = 'Command failed'; + _commandRefusalHandler?.call(reason); + return {'id': id, 'accepted': false, 'reason': reason}; } } @@ -214,5 +241,6 @@ class WatchBridgeService { _scheduledUpdate = null; _pendingSnapshotBuilder = null; _commandHandler = null; + _commandRefusalHandler = null; } } From e9982271e77d5179961d9a0d7070e5167caffa67 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 09:40:41 -0700 Subject: [PATCH 21/71] Start the mode the region allows, and name it on the button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starting a session from the wrist did nothing, while stopping produced a cooldown that passive mode never creates. `_handleWatchCommand` called `toggleAutoPing(_autoMode)`, but `_autoMode` defaults to Active and is only assigned inside `toggleAutoPing` when a mode actually starts. So until a mode had been started *on the phone*, the wrist started Active — which in Adam's passive-only region is the one mode forbidden there. His Live Activity had been reporting this all along: the "circle with a line through it" is the `txBlocked` phase, which fires on exactly `(_autoMode == active|hybrid|targeted) && !txAllowed`. Pressing Passive on the phone set `_autoMode`, which is why every wrist toggle worked afterwards. The phone never hit this because each of its buttons passes an explicit mode. Only the wrist inherited an implicit one, and the default happened to be the forbidden one. `_resolvedWatchSessionMode` now decides: a running session keeps its own mode, so the wrist stops what it started; otherwise a region that forbids TX resolves to Passive; otherwise the wearer's last choice stands. The button says which mode it will start — "Start Passive" — because a wrist control that silently picks a mode is only safe while the guess is right. That label reads from the same resolver as the action; sourcing it from the ambient `_autoMode` would have traded a silent wrong action for a visible lie. Only the watch payload's `mode` changed: the Live Activity keeps `_liveActivityModeTitle`, since it reports the session that is running rather than the one a button would start. --- ios/MeshMapperWatch/ControlsPage.swift | 8 ++++- lib/providers/app_state_provider.dart | 49 +++++++++++++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index 13735d3..d891f69 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -65,12 +65,18 @@ struct ControlsPage: View { let kind: WatchCommand.Kind = isActive ? .stopSession : .startSession let isPending = client.pendingCommand == kind let isEnabled = controls?.canStartStop == true && !isPending + let startTitle = "Start \(client.snapshot?.mode ?? "Session")" + let buttonTitle = isPending + ? (isActive ? "Stopping…" : "Starting…") + : (isActive ? "Stop" : startTitle) return Button { client.send(kind) } label: { - Text(isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : "Start")) + Text(buttonTitle) .font(.headline) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, minHeight: 44) // A ProgressView accepts the horizontal slack offered by a stack. As // an overlay it can appear without participating in the label's diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 06e08db..e97d1e6 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1228,7 +1228,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final core = LiveActivitySnapshot( sessionId: _liveActivitySessionId ?? 'idle', - mode: _liveActivityModeTitle, + // 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, @@ -1417,6 +1420,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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; + } + + 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; @@ -1464,12 +1486,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { case WatchCommandKind.startSession: if (!isConnected) return 'Not connected'; if (_autoPingEnabled) return null; // Already running. - unawaited(_runWatchStartSession()); + unawaited(_runWatchStartSession(_resolvedWatchSessionMode)); return null; case WatchCommandKind.stopSession: if (!_autoPingEnabled) return null; // Already stopped. - unawaited(_runWatchStopSession()); + unawaited(_runWatchStopSession(_resolvedWatchSessionMode)); return null; case WatchCommandKind.manualPing: @@ -1482,22 +1504,22 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } - Future _runWatchStartSession() async { + Future _runWatchStartSession(AutoMode mode) async { _lastSessionCheckFailureReason = null; try { - final started = await toggleAutoPing(_autoMode); + final started = await toggleAutoPing(mode); if (!started) { - _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Could not start'); + _emitWatchFailure(_watchStartFailureReason(mode)); } } catch (error) { debugError('[WATCH] startSession failed after admission: $error'); - _emitWatchFailure(_lastSessionCheckFailureReason ?? 'Could not start'); + _emitWatchFailure(_watchStartFailureReason(mode)); } } - Future _runWatchStopSession() async { + Future _runWatchStopSession(AutoMode mode) async { try { - final stopped = await toggleAutoPing(_autoMode); + final stopped = await toggleAutoPing(mode); if (!stopped) _emitWatchFailure('Could not stop'); } catch (error) { debugError('[WATCH] stopSession failed after admission: $error'); @@ -1505,6 +1527,15 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + 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 { From 2768c79c621be27be4fefcc400731dc912c748fa Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 10:28:36 -0700 Subject: [PATCH 22/71] Rebuild the Live Activity on the map overlay's vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The design of the live event panel on the watch and iphone leave a lot to be desired. We have better design elements in the app we should repurpose." The elements worth repurposing are the ones now signed off on the wrist: a depleting countdown bar, and rows of hex identity with a ping-type dot and a quality-coloured SNR. Most of the gap was data, not styling. `ContentState` carried `phaseEndsAt` but no duration, so a progress bar could only be full or empty. `HeardRepeater` was `{id, name, snr}` with no ping type and no colour, so every dot was painted the same grey-teal and the distinction between a discovery answer, a flood answer and an RX packet — which the map overlay is built around — could not be drawn at all. Nothing carried the last ping's outcome, so an unanswered ping, a real negative result when mapping coverage, looked identical to a cycle that had not reported yet. And the extension hardcoded three colours, quietly ignoring the colour-vision palettes that the watch honours for free. So the wire gains `phaseDurationMs`, `pingColor`, and per-repeater `typeColor`/`snrColor`, all resolved on the phone through the same helpers the watch already uses rather than a second set. Colour policy stays in Dart, where the palettes live. Layouts, per surface's real constraints: the lock screen shows hex *and* resolved name, because that is what the larger display is for; the watch small family shows hex only, since the hash is the identity and there is no room for more; the island's minimal presentation carries the outcome colour, since one mark should be the most valuable one. A lapsed deadline dims its title to 45% and drops the countdown, matching the rule the watch already follows — these surfaces can sit on a stale state for a long time, and neither should keep asserting a phase it can no longer vouch for. Reviewed as rendered pixels, not as code. The content views take a plain `ContentState`, so `ImageRenderer` can draw them headlessly — nine images across three states at each surface's real width. That caught what reading could not: the metrics sat on the third repeater row's baseline, so `91CE -8.7 dB TX 42 RX 318` read as one line and the session totals looked like properties of a repeater. They now share the badge row, taking the space back from `phaseDetail`, which on the lock screen only restated the bar above it. The detail stays in the payload — several phases carry information the bar does not, and the island's centre region is the place for it. Session-end summary is deliberately not here; it is the next round. --- .../MeshMapperLiveActivity.swift | 736 ++++++++++++------ ios/Runner/LiveActivityManager.swift | 32 +- ios/Shared/MeshMapperActivityAttributes.swift | 17 +- lib/providers/app_state_provider.dart | 36 +- .../live_activity/live_activity_models.dart | 18 + lib/services/watch/watch_color.dart | 29 + lib/services/watch/watch_geo_builder.dart | 6 +- lib/services/watch/watch_models.dart | 32 +- 8 files changed, 630 insertions(+), 276 deletions(-) create mode 100644 lib/services/watch/watch_color.dart diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index efe9fa7..e959df4 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -20,10 +20,13 @@ struct MeshMapperLiveActivity: Widget { MeshMapperModeBadge(mode: context.state.mode) } DynamicIslandExpandedRegion(.trailing) { - MeshMapperBestSignal(state: context.state) + MeshMapperIslandOutcome(state: context.state, isStale: context.isStale) } DynamicIslandExpandedRegion(.center) { - MeshMapperPhaseLabel(state: context.state) + 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) @@ -33,19 +36,22 @@ struct MeshMapperLiveActivity: Widget { systemName: context.isStale ? "exclamationmark.triangle.fill" : context.state.phaseSymbol ) - .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor) + .foregroundStyle(context.isStale ? Color.orange : context.state.displayColor) .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle) } compactTrailing: { MeshMapperCompactTrailing(state: context.state) } minimal: { - 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) + 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) + } } - .keylineTint(context.state.phaseColor) + .keylineTint(context.state.displayColor) } .meshMapperSupplementalActivityFamilies() } @@ -68,9 +74,15 @@ private struct MeshMapperResponsiveActivityView: View { var body: some View { if activityFamily == .small { - MeshMapperSmallActivityView(context: context) + MeshMapperSmallActivityContent( + state: context.state, + isStale: context.isStale + ) } else { - MeshMapperLockScreenView(context: context) + MeshMapperLockScreenContent( + state: context.state, + isStale: context.isStale + ) } } } @@ -79,58 +91,44 @@ private struct MeshMapperLockScreenView: View { let context: ActivityViewContext var body: some View { - VStack(alignment: .leading, spacing: 9) { - HStack(spacing: 8) { - MeshMapperModeBadge(mode: context.state.mode) - Spacer(minLength: 8) - MeshMapperStatusLabel(context: context) - } + MeshMapperLockScreenContent( + state: context.state, + isStale: context.isStale + ) + } +} - HStack(alignment: .center, spacing: 9) { - Image(systemName: context.state.phaseSymbol) - .font(.title3.weight(.semibold)) - .foregroundStyle(context.state.phaseColor) - .frame(width: 24) - .accessibilityHidden(true) +private struct MeshMapperLockScreenContent: View { + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool - VStack(alignment: .leading, spacing: 1) { - Text(context.state.phaseTitle) - .font(.headline.weight(.semibold)) - .lineLimit(1) - if let detail = context.state.phaseDetail, !detail.isEmpty { - Text(detail) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } + var body: some View { + VStack(alignment: .leading, spacing: 9) { + MeshMapperPhaseBar( + state: state, + height: 20, + 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) - MeshMapperCountdown( - state: context.state, - font: .headline.monospacedDigit().weight(.semibold) - ) + MeshMapperStatusLabel(state: state, isStale: isStale) } - HStack(alignment: .bottom, spacing: 12) { - MeshMapperRepeaterSummary(state: context.state) - .frame(maxWidth: .infinity, alignment: .leading) - - VStack(alignment: .trailing, spacing: 4) { - HStack(spacing: 9) { - MeshMapperMetric( - label: context.state.primaryMetricLabel, - value: context.state.primaryMetricValue - ) - MeshMapperMetric(label: "RX", value: context.state.rxCount) - } - if context.state.queueSize > 0 { - Label("Queue \(context.state.queueSize)", systemImage: "arrow.triangle.2.circlepath") - .font(.caption2.monospacedDigit().weight(.medium)) - .foregroundStyle(.secondary) - } - } - } + MeshMapperRepeaterSummary( + state: state, + limit: 3, + showsNames: true, + rowFont: .caption + ) + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.horizontal, 14) .padding(.vertical, 12) @@ -139,85 +137,128 @@ private struct MeshMapperLockScreenView: View { } @available(iOSApplicationExtension 18.0, *) -private struct MeshMapperSmallActivityView: View { - let context: ActivityViewContext +private struct MeshMapperSmallActivityContent: View { + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 7) { - Image(systemName: context.state.phaseSymbol) - .foregroundStyle(context.state.phaseColor) - .accessibilityHidden(true) - Text(context.state.mode.uppercased()) + VStack(alignment: .leading, spacing: 7) { + MeshMapperPhaseBar( + state: state, + height: 18, + 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: 4) - if context.isStale { + Spacer(minLength: 3) + if isStale { Image(systemName: "exclamationmark.triangle.fill") .font(.caption2) .foregroundStyle(.orange) .accessibilityLabel("Update delayed") - } else if let zone = context.state.zoneCode { - Text(zone) - .font(.system(.caption2, design: .monospaced).weight(.semibold)) - .foregroundStyle(.secondary) + } else { + MeshMapperOutcomeDot(state: state, diameter: 7) + if let zone = state.zoneCode { + Text(zone) + .font(.system(.caption2, design: .monospaced).weight(.semibold)) + .foregroundStyle(.secondary) + } } } - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(context.state.phaseTitle) - .font(.headline.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - Spacer(minLength: 4) - MeshMapperCountdown( - state: context.state, - font: .headline.monospacedDigit().weight(.semibold) - ) - } + MeshMapperRepeaterSummary( + state: state, + limit: 2, + showsNames: false, + rowFont: .caption2 + ) + + MeshMapperMetrics(state: state, compact: true) + } + .padding(12) + .foregroundStyle(.white) + } +} - MeshMapperBestRepeaterRow(state: context.state) +/// The phase as a locally depleting bar, matching the watch map panel. +/// +/// Absolute deadline plus duration lets SwiftUI animate between sparse phone +/// updates. The title and countdown ride over the fill so neither consumes +/// track width, and their shadow keeps them legible on both halves. +private struct MeshMapperPhaseBar: View { + let state: MeshMapperActivityAttributes.ContentState + let height: CGFloat + let titleFont: Font + let countdownFont: Font + let countdownWidth: CGFloat - HStack(spacing: 10) { - Text("\(context.state.primaryMetricLabel) \(context.state.primaryMetricValue)") - Text("RX \(context.state.rxCount)") - Spacer(minLength: 0) - if context.state.queueSize > 0 { - Label("\(context.state.queueSize)", systemImage: "arrow.triangle.2.circlepath") + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { context in + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(state.displayColor) + .frame( + width: geometry.size.width + * (state.phaseRemainingFraction(at: context.date) ?? 0) + ) + } + .overlay { + HStack { + Text(state.phaseTitle) + .font(titleFont) + .foregroundStyle( + .white.opacity(state.deadlineLapsed(at: context.date) ? 0.45 : 1) + ) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + MeshMapperCountdown( + state: state, + at: context.date, + font: countdownFont + ) + .frame(width: countdownWidth, alignment: .trailing) + } + .padding(.horizontal, 7) + .shadow(color: .black.opacity(0.7), radius: 1.5) } } - .font(.caption2.monospacedDigit().weight(.medium)) - .foregroundStyle(.secondary) } - .padding(12) - .foregroundStyle(.white) + .frame(height: height) } } private struct MeshMapperStatusLabel: View { - let context: ActivityViewContext + let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool var body: some View { Label( - context.isStale - ? "Update delayed" : context.state.zoneCode ?? context.state.connectionLabel, - systemImage: context.isStale + isStale ? "Update delayed" : state.zoneCode ?? state.connectionLabel, + systemImage: isStale ? "exclamationmark.triangle.fill" - : context.state.isConnected + : state.isConnected ? "antenna.radiowaves.left.and.right" : "wifi.slash" ) .font(.caption2.weight(.semibold)) .lineLimit(1) - .foregroundStyle( - context.isStale || !context.state.isConnected - ? Color.orange : MeshMapperPalette.secondary - ) + .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) { @@ -225,93 +266,109 @@ private struct MeshMapperRepeaterSummary: View { Text(state.repeatersAreCurrent ? "HEARD NOW" : "LAST HEARD") .font(.caption2.weight(.bold)) .tracking(0.6) - .foregroundStyle(.secondary) if state.totalHeardCount > 0 { Text("\(state.totalHeardCount)") .font(.caption2.monospacedDigit().weight(.semibold)) - .foregroundStyle(.secondary) } } + .foregroundStyle(.secondary) if state.repeaters.isEmpty { - Text(state.repeaterEmptyLabel) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + 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(2)) { repeater in - HStack(spacing: 6) { - Circle() - .fill(MeshMapperPalette.secondary) - .frame(width: 5, height: 5) - .accessibilityHidden(true) - Text(repeater.displayName) - .font(.caption.weight(.medium)) - .lineLimit(1) - Text(repeater.snr.formattedSnr) - .font(.caption.monospacedDigit().weight(.semibold)) - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(repeater.displayName), SNR \(repeater.snr.formattedSnr)") + ForEach(state.repeaters.prefix(limit)) { repeater in + MeshMapperRepeaterRow( + repeater: repeater, + fallbackColor: state.displayColor, + showsName: showsNames, + font: rowFont + ) } } } } } -private struct MeshMapperBestRepeaterRow: View { - let state: MeshMapperActivityAttributes.ContentState +private struct MeshMapperRepeaterRow: View { + let repeater: MeshMapperActivityAttributes.HeardRepeater + let fallbackColor: Color + let showsName: Bool + let font: Font var body: some View { - if let best = state.repeaters.first { - HStack(spacing: 6) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.caption2) - .foregroundStyle(MeshMapperPalette.secondary) - .accessibilityHidden(true) - Text(best.displayName) - .font(.caption.weight(.medium)) + 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) - Spacer(minLength: 4) - Text(best.snr.formattedSnr) - .font(.caption.monospacedDigit().weight(.semibold)) - if state.totalHeardCount > 1 { - Text("+\(state.totalHeardCount - 1)") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - } + .truncationMode(.tail) } - .accessibilityElement(children: .ignore) - .accessibilityLabel( - "Best repeater \(best.displayName), SNR \(best.snr.formattedSnr), " - + "\(state.totalHeardCount) heard" - ) - } else { - Text(state.repeaterEmptyLabel) - .font(.caption) - .foregroundStyle(.secondary) + 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 MeshMapperIslandBottom: View { +private struct MeshMapperMetrics: View { let state: MeshMapperActivityAttributes.ContentState + let compact: Bool var body: some View { - VStack(spacing: 6) { - MeshMapperBestRepeaterRow(state: state) - HStack(spacing: 12) { - Text("\(state.primaryMetricLabel) \(state.primaryMetricValue)") - Text("RX \(state.rxCount)") - if let zone = state.zoneCode { - Spacer() - Text(zone) - } + 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(.caption.monospacedDigit().weight(.medium)) - .foregroundStyle(.secondary) + } + .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, + height: 18, + 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) } } } @@ -326,46 +383,40 @@ private struct MeshMapperModeBadge: View { .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) - .background(MeshMapperPalette.primary, in: Capsule()) + // 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 MeshMapperPhaseLabel: View { +private struct MeshMapperIslandOutcome: View { let state: MeshMapperActivityAttributes.ContentState + let isStale: Bool var body: some View { - VStack(spacing: 2) { - Text(state.phaseTitle) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - MeshMapperCountdown( - state: state, - font: .caption.monospacedDigit().weight(.semibold) - ) - .foregroundStyle(.secondary) + 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 MeshMapperBestSignal: View { +private struct MeshMapperOutcomeDot: View { let state: MeshMapperActivityAttributes.ContentState + let diameter: CGFloat var body: some View { - if let best = state.repeaters.first { - VStack(alignment: .trailing, spacing: 2) { - Text(best.snr.formattedSnr) - .font(.subheadline.monospacedDigit().weight(.semibold)) - Text("best SNR") - .font(.caption2) - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") - } else { - Image(systemName: "waveform.slash") - .foregroundStyle(.secondary) - .accessibilityLabel("No repeaters heard") - } + Circle() + .fill(state.displayColor) + .frame(width: diameter, height: diameter) + .accessibilityLabel("Latest ping result") } } @@ -373,31 +424,34 @@ private struct MeshMapperCompactTrailing: View { let state: MeshMapperActivityAttributes.ContentState var body: some View { - if state.hasActiveCountdown { - MeshMapperCountdown( - state: state, - font: .caption2.monospacedDigit().weight(.bold) - ) - .frame(minWidth: 28) - } else if let best = state.repeaters.first { - Text(best.snr.formattedSnr) - .font(.caption2.monospacedDigit().weight(.bold)) - .accessibilityLabel("Best SNR \(best.snr.formattedSnr)") - } else { - Text("\(state.rxCount)") - .font(.caption2.monospacedDigit().weight(.bold)) - .accessibilityLabel("\(state.rxCount) received") + TimelineView(.periodic(from: .now, by: 1)) { context in + if state.hasActiveCountdown(at: context.date) { + MeshMapperCountdown( + state: state, + at: context.date, + 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 + let at: Date let font: Font var body: some View { - if let end = state.phaseEndsAt, end > Date() { - Text(timerInterval: Date()...end, countsDown: true, showsHours: false) + if let end = state.phaseEndsAt, end > at { + Text(timerInterval: at...end, countsDown: true, showsHours: false) .font(font) .lineLimit(1) .accessibilityLabel("Time remaining") @@ -405,30 +459,11 @@ private struct MeshMapperCountdown: View { } } -private struct MeshMapperMetric: View { - let label: String - let value: Int - - var body: some View { - Text("\(label) \(value)") - .font(.caption.monospacedDigit().weight(.semibold)) - .foregroundStyle(.secondary) - .accessibilityLabel("\(label) \(value)") - } -} - private enum MeshMapperPalette { static let background = Color(red: 0.055, green: 0.075, blue: 0.105) - static let primary = Color(red: 0.12, green: 0.43, blue: 0.92) - static let secondary = Color(red: 0.30, green: 0.82, blue: 0.78) } extension MeshMapperActivityAttributes.ContentState { - fileprivate var hasActiveCountdown: Bool { - guard let phaseEndsAt else { return false } - return phaseEndsAt > Date() - } - fileprivate var connectionLabel: String { isConnected ? "Connected" : "Disconnected" } @@ -452,9 +487,9 @@ extension MeshMapperActivityAttributes.ContentState { fileprivate var repeaterEmptyLabel: String { switch phase { case "listening", "listening_discovery", "listening_trace": - return "No repeaters heard yet" + return "Nothing heard" default: - return "No repeaters heard in the last cycle" + return "Nothing heard in the last cycle" } } @@ -476,10 +511,13 @@ extension MeshMapperActivityAttributes.ContentState { } } - fileprivate var phaseColor: Color { + /// Phone-resolved outcome colour wins whenever one exists. System colours + /// are only a fallback before a session has produced a ping result. + fileprivate var displayColor: Color { + if let pingColor { return Color(pingColor) } switch phase { - case "sending", "discovering", "tracing": return MeshMapperPalette.primary - case "listening", "listening_discovery", "listening_trace": return MeshMapperPalette.secondary + 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 @@ -487,6 +525,54 @@ extension MeshMapperActivityAttributes.ContentState { default: return .white } } + + fileprivate func hasActiveCountdown(at date: Date) -> Bool { + guard let phaseEndsAt else { return false } + return phaseEndsAt > date + } + + fileprivate func deadlineLapsed(at date: Date) -> Bool { + guard let phaseEndsAt else { return false } + return phaseEndsAt <= date + } + + /// Fraction remaining in the current countdown, calculated locally so the + /// Live Activity does not need a state update every second. + fileprivate func phaseRemainingFraction(at date: Date) -> CGFloat? { + guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { + return nil + } + let remaining = phaseEndsAt.timeIntervalSince(date) + guard remaining > 0 else { return 0 } + return CGFloat(min(1, remaining / (Double(phaseDurationMs) / 1000))) + } +} + +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 { @@ -495,3 +581,193 @@ extension Double { 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. +/// +/// 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/Runner/LiveActivityManager.swift b/ios/Runner/LiveActivityManager.swift index a806a08..1643387 100644 --- a/ios/Runner/LiveActivityManager.swift +++ b/ios/Runner/LiveActivityManager.swift @@ -98,7 +98,9 @@ final class LiveActivityManager { return MeshMapperActivityAttributes.HeardRepeater( id: id, name: boundedString(item["name"], maxLength: 36), - snr: min(max(snr, -200), 200) + snr: min(max(snr, -200), 200), + typeColor: resolvedColor(item["typeColor"]), + snrColor: resolvedColor(item["snrColor"]) ) } @@ -109,6 +111,8 @@ final class LiveActivityManager { 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"]), @@ -200,6 +204,7 @@ final class LiveActivityManager { finalState.phaseTitle = "Session ended" finalState.phaseDetail = summary(for: finalState) finalState.phaseEndsAt = nil + finalState.phaseDurationMs = nil finalState.repeatersAreCurrent = false finalState.updatedAt = Date() @@ -255,4 +260,29 @@ final class LiveActivityManager { 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/Shared/MeshMapperActivityAttributes.swift b/ios/Shared/MeshMapperActivityAttributes.swift index 25f16d2..c42c029 100644 --- a/ios/Shared/MeshMapperActivityAttributes.swift +++ b/ios/Shared/MeshMapperActivityAttributes.swift @@ -5,15 +5,20 @@ import Foundation /// 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 - - var displayName: String { - let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return trimmed.isEmpty ? id : trimmed - } + let typeColor: ResolvedColor? + let snrColor: ResolvedColor? } struct ContentState: Codable, Hashable { @@ -22,6 +27,8 @@ struct MeshMapperActivityAttributes: ActivityAttributes { var phaseTitle: String var phaseDetail: String? var phaseEndsAt: Date? + var phaseDurationMs: Int? + var pingColor: ResolvedColor? var isConnected: Bool var zoneCode: String? var txCount: Int diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index e97d1e6..dfb5347 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -249,7 +249,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 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})> _liveActivityRepeaters = []; + List<({String repeaterId, double snr, OverlayPingType type})> + _liveActivityRepeaters = []; int _liveActivityRepeaterTotalCount = 0; DateTime? _liveActivityRepeatersUpdatedAt; DateTime? _liveActivityRxUpdatedAt; @@ -632,7 +633,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } void _updateLiveActivityRepeaters( - Iterable<({String repeaterId, double snr})> current) { + Iterable<({String repeaterId, double snr})> current, + OverlayPingType type) { final bestSnr = {}; for (final repeater in current) { if (!repeater.snr.isFinite) continue; @@ -644,7 +646,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } final sorted = bestSnr.entries - .map((entry) => (repeaterId: entry.key, snr: entry.value)) + .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); @@ -1225,6 +1228,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final phase = _resolveLiveActivityPhase(); final repeaterState = _buildLiveActivityRepeaters(); final now = DateTime.now(); + final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); + final pingColor = _resolveWatchPingColor(); final core = LiveActivitySnapshot( sessionId: _liveActivitySessionId ?? 'idle', @@ -1236,6 +1241,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { phaseTitle: phase.title, phaseDetail: phase.detail, phaseEndsAt: phase.endsAt, + phaseDurationMs: phaseDurationMs, + pingColor: pingColor, isConnected: isConnected, zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, txCount: _pingStats.txCount, @@ -1253,9 +1260,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { core: core, geo: _buildWatchGeo(now), controls: _buildWatchControls(), - pingColor: _resolveWatchPingColor(), + pingColor: pingColor, cue: _watchCue, - phaseDurationMs: _phaseDurationMsFor(phase.endsAt), + phaseDurationMs: phaseDurationMs, updatedAt: now, ); } @@ -1567,6 +1574,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final phase = _resolveLiveActivityPhase(); final repeaterState = _buildLiveActivityRepeaters(); + final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); + final pingColor = _resolveWatchPingColor(); return LiveActivitySnapshot( sessionId: sessionId, @@ -1575,6 +1584,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { phaseTitle: phase.title, phaseDetail: phase.detail, phaseEndsAt: phase.endsAt, + phaseDurationMs: phaseDurationMs, + pingColor: pingColor, isConnected: isConnected, zoneCode: zoneCode ?? _sessionZoneCode ?? _preferences.iataCode, txCount: _pingStats.txCount, @@ -1810,6 +1821,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: id, name: _resolveRepeaterDisplayName(id), snr: repeater.snr, + typeColor: WatchGeoBuilder.overlayTypeColor(repeater.type), + snrColor: WatchGeoBuilder.snrColor(repeater.snr), ); } } @@ -1823,6 +1836,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { id: id, name: _resolveRepeaterDisplayName(id), snr: rx.snr, + typeColor: WatchGeoBuilder.overlayTypeColor(OverlayPingType.rx), + snrColor: WatchGeoBuilder.snrColor(rx.snr), ); } } @@ -3468,7 +3483,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: event.repeaterId.toUpperCase(), snr: event.snr!, )), - ]); + ], OverlayPingType.tx); debugLog('[APP] Calling notifyListeners() to update UI'); _notifyMapThrottled(); @@ -3536,7 +3551,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterId: event.repeaterId.toUpperCase(), snr: event.snr!, )), - ]); + ], OverlayPingType.tx); _notifyMapThrottled(); } @@ -3582,7 +3597,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { )) .toList(growable: false); _updateTopRepeaters(heardRepeaters, OverlayPingType.disc); - _updateLiveActivityRepeaters(heardRepeaters); + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc); _notifyMapThrottled(); }; @@ -3633,7 +3648,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { snr: event.snr!, ))); } - _updateLiveActivityRepeaters(heardRepeaters); + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.tx); final PingEventType eventType; if (directSuccess) { @@ -3680,7 +3695,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { snr: node.localSnr, ))); } - _updateLiveActivityRepeaters(heardRepeaters); + _updateLiveActivityRepeaters(heardRepeaters, OverlayPingType.disc); PingEventType eventType; if (success) { @@ -3742,6 +3757,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { result != null && result.success && traceSnr != null ? [(repeaterId: result.targetRepeaterId, snr: traceSnr)] : const [], + OverlayPingType.trace, ); recordPingEvent( diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index ec93f64..83d5e30 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -1,3 +1,5 @@ +import '../watch/watch_color.dart'; + /// High-level phase shown by the iOS Live Activity. enum LiveActivityPhase { active, @@ -49,16 +51,22 @@ class 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(), }; } @@ -81,6 +89,8 @@ class LiveActivitySnapshot { required this.updatedAt, this.phaseDetail, this.phaseEndsAt, + this.phaseDurationMs, + this.pingColor, this.zoneCode, }); @@ -90,6 +100,8 @@ class LiveActivitySnapshot { final String phaseTitle; final String? phaseDetail; final DateTime? phaseEndsAt; + final int? phaseDurationMs; + final WatchColor? pingColor; final bool isConnected; final String? zoneCode; final int txCount; @@ -109,6 +121,8 @@ class LiveActivitySnapshot { 'phaseTitle': phaseTitle, 'phaseDetail': phaseDetail, 'phaseEndsAt': phaseEndsAt?.millisecondsSinceEpoch, + if (phaseDurationMs != null) 'phaseDurationMs': phaseDurationMs, + if (pingColor != null) 'pingColor': pingColor!.toMap(), 'isConnected': isConnected, 'zoneCode': zoneCode, 'txCount': txCount, @@ -130,6 +144,10 @@ class LiveActivitySnapshot { phaseTitle, phaseDetail ?? '', phaseEndsAt?.millisecondsSinceEpoch ?? 0, + phaseDurationMs ?? 0, + pingColor?.r ?? '', + pingColor?.g ?? '', + pingColor?.b ?? '', isConnected, zoneCode ?? '', ].join('|'); diff --git a/lib/services/watch/watch_color.dart b/lib/services/watch/watch_color.dart new file mode 100644 index 0000000..6c62c4a --- /dev/null +++ b/lib/services/watch/watch_color.dart @@ -0,0 +1,29 @@ +import 'dart:ui' show Color; + +/// An sRGB colour resolved from the active colour-vision palette. +/// +/// Resolving on the phone is deliberate: Dart owns the palettes, while native +/// glance surfaces receive the same small RGB value and never need to duplicate +/// accessibility policy. +class WatchColor { + const WatchColor(this.r, this.g, this.b); + + factory WatchColor.fromColor(Color color) => WatchColor( + (color.r * 255.0).roundToDouble() / 255.0, + (color.g * 255.0).roundToDouble() / 255.0, + (color.b * 255.0).roundToDouble() / 255.0, + ); + + final double r; + final double g; + final double b; + + Map toMap() => {'r': r, 'g': g, 'b': b}; + + @override + bool operator ==(Object other) => + other is WatchColor && other.r == r && other.g == g && other.b == b; + + @override + int get hashCode => Object.hash(r, g, b); +} diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index a3ab903..e14e18c 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -160,6 +160,10 @@ class WatchGeoBuilder { OverlayPingType.rx => WatchColor.fromColor(PingColors.rx), }; + /// SNR quality colour shared by every native glance surface. + static WatchColor snrColor(double snr) => + WatchColor.fromColor(PingColors.snrColor(snr)); + /// The "Top Heard" overlay rows: up to three top-SNR repeaters from the /// latest ping, then the current RX slot. /// @@ -230,7 +234,7 @@ class WatchGeoBuilder { snr: snr, at: at, distanceM: distance, - snrColor: WatchColor.fromColor(PingColors.snrColor(snr)), + snrColor: snrColor(snr), typeColor: overlayTypeColor(type), ); } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 4de1700..34c2a3d 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -1,6 +1,7 @@ -import 'dart:ui' show Color; - import '../live_activity/live_activity_models.dart'; +import 'watch_color.dart'; + +export 'watch_color.dart'; /// Wire contract for the watchOS companion. /// @@ -36,33 +37,6 @@ class WatchWire { static const double minMoveMeters = 15.0; } -/// An sRGB colour resolved from the active colour-vision palette. -/// -/// Resolving on the phone is deliberate: Dart owns [PingColors], so the watch -/// renders accessibility palettes correctly without duplicating any of them. -class WatchColor { - const WatchColor(this.r, this.g, this.b); - - factory WatchColor.fromColor(Color color) => WatchColor( - (color.r * 255.0).roundToDouble() / 255.0, - (color.g * 255.0).roundToDouble() / 255.0, - (color.b * 255.0).roundToDouble() / 255.0, - ); - - final double r; - final double g; - final double b; - - Map toMap() => {'r': r, 'g': g, 'b': b}; - - @override - bool operator ==(Object other) => - other is WatchColor && other.r == r && other.g == g && other.b == b; - - @override - int get hashCode => Object.hash(r, g, b); -} - class WatchPosition { const WatchPosition({ required this.lat, From 224772d2a5a77926543010b846bbe8062f2473fb Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 10:51:01 -0700 Subject: [PATCH 23/71] Stop telegraphing a routine result: de-tint the bar and keyline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam, on the render: "Don't tint the bar. That's too much of an error telegraph for a common case." He is right, and the mistake was mine. An unanswered ping is the normal outcome in thin coverage — it is the thing being mapped, not a fault. Filling the whole progress bar red made an ordinary result look like a system failure, and in doing so left nothing louder for actual errors. The bar and the Dynamic Island keyline now take a neutral accent, and the compact-leading glyph follows the phase rather than the last ping. The outcome stays legible where it belongs: the outcome dot on the small and minimal presentations, the coloured dot beside "Nothing heard", and the per-repeater type dots. None of those were made louder to compensate — the point is that a routine negative result should be available, not announced. The watch's own bar has the same tint and arguably the same problem, but that surface was signed off on hardware, so it is asked about rather than changed here. Verified by re-rendering all nine states through the harness. --- .../MeshMapperLiveActivity.swift | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index e959df4..c3dc215 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -36,7 +36,7 @@ struct MeshMapperLiveActivity: Widget { systemName: context.isStale ? "exclamationmark.triangle.fill" : context.state.phaseSymbol ) - .foregroundStyle(context.isStale ? Color.orange : context.state.displayColor) + .foregroundStyle(context.isStale ? Color.orange : context.state.phaseColor) .accessibilityLabel(context.isStale ? "Update delayed" : context.state.phaseTitle) } compactTrailing: { MeshMapperCompactTrailing(state: context.state) @@ -51,7 +51,9 @@ struct MeshMapperLiveActivity: Widget { MeshMapperOutcomeDot(state: context.state, diameter: 9) } } - .keylineTint(context.state.displayColor) + // 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() } @@ -203,7 +205,9 @@ private struct MeshMapperPhaseBar: View { ZStack(alignment: .leading) { Capsule().fill(.white.opacity(0.16)) Capsule() - .fill(state.displayColor) + // Progress says how much time remains. Outcome has quieter, + // dedicated dots elsewhere and must not recolour the whole track. + .fill(MeshMapperPalette.accent) .frame( width: geometry.size.width * (state.phaseRemainingFraction(at: context.date) ?? 0) @@ -287,7 +291,7 @@ private struct MeshMapperRepeaterSummary: View { ForEach(state.repeaters.prefix(limit)) { repeater in MeshMapperRepeaterRow( repeater: repeater, - fallbackColor: state.displayColor, + fallbackColor: state.outcomeColor, showsName: showsNames, font: rowFont ) @@ -414,7 +418,7 @@ private struct MeshMapperOutcomeDot: View { var body: some View { Circle() - .fill(state.displayColor) + .fill(state.outcomeColor) .frame(width: diameter, height: diameter) .accessibilityLabel("Latest ping result") } @@ -461,6 +465,7 @@ private struct MeshMapperCountdown: View { private enum MeshMapperPalette { static let background = Color(red: 0.055, green: 0.075, blue: 0.105) + static let accent = Color.accentColor } extension MeshMapperActivityAttributes.ContentState { @@ -511,10 +516,16 @@ extension MeshMapperActivityAttributes.ContentState { } } - /// Phone-resolved outcome colour wins whenever one exists. System colours - /// are only a fallback before a session has produced a ping result. - fileprivate var displayColor: Color { + /// 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 From a6d7e2dd50e2a06c2b036c2b695479aa94868037 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 11:01:52 -0700 Subject: [PATCH 24/71] Give the watch map its discovery and trace pings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The teal passive pings aren't being displayed as dots on the map or they are being rendered as purple rx dots." They were never displayable. `buildPings` took only `txPings` and `rxPings`, and `_buildWatchGeo` passed exactly those — so the builder's `pingColor('disc', …)` teal branch and its `trace` branch were unreachable code, and RX purple was the only non-TX colour the watch could draw. Green was fine: it is on the path that runs. The phone draws these from sources the watch was never handed: `discLogEntries`, where success is `discoveredNodes.isNotEmpty`, and `traceLogEntries`, which carries `success` outright. Both now reach the wire, with the phone's own success rules rather than new ones. The cap needed rethinking with four sources. It applied to a list built as "all TX, then all RX", which with discovery added could have kept sixty TX markers and dropped every teal one — the same bug wearing a different hat. Candidates are now sorted newest-first across all types before the cap, so history thins evenly instead of a category vanishing. Also mirrors the phone's multi-hop rule, which is the other half of what he saw: a TX answered only through multi-hop draws as an RX marker, because that is what it evidences — the packet returned, but not directly. `pathHops == null` marks a direct echo. Four types, four colour rules, and until now nothing asserted that a discovery ping ever reached the wire at all — which is exactly how a whole category went missing unnoticed. The tests do that now, including that the cap starves no single type. Not fixed here, and reported separately: RX-only repeaters never get the current-cycle ring, because `heardIds` is built from `_topRepeatersOverlay` alone and omits `_rxOverlaySlot`. That is the repeater pins, not the ping markers, so it does not belong in this change. --- lib/providers/app_state_provider.dart | 7 +- lib/services/watch/watch_geo_builder.dart | 44 +++- .../watch/watch_geo_builder_test.dart | 210 +++++++++++++++++- 3 files changed, 256 insertions(+), 5 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index dfb5347..ad7f1f7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1314,7 +1314,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchGeo( you: position, - pings: WatchGeoBuilder.buildPings(txPings: _txPings, rxPings: _rxPings), + pings: WatchGeoBuilder.buildPings( + txPings: _txPings, + rxPings: _rxPings, + discLogEntries: _discLogEntries, + traceLogEntries: _traceLogEntries, + ), repeaters: WatchGeoBuilder.buildRepeaters( repeaters: _repeaters, heardThisCycle: heardIds, diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index e14e18c..aa68bda 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import '../../models/log_entry.dart'; import '../../models/ping_data.dart'; import '../../models/repeater.dart'; import '../../providers/app_state_provider.dart' show OverlayPingType; @@ -68,11 +69,16 @@ class WatchGeoBuilder { /// Most recent pings, newest first, capped at [WatchWire.maxPings]. /// - /// TX and RX are merged into one time-ordered stream because the watch map - /// shows them together; a TX that nobody answered is drawn as a failure. + /// Every coverage source is merged before the recency cap is applied. + /// + /// Applying the cap to source-ordered batches could let a busy TX history + /// erase discovery or trace markers. Sorting the complete candidate set + /// first makes the wire carry the latest drive history regardless of type. static List buildPings({ required List txPings, required List rxPings, + required List discLogEntries, + required List traceLogEntries, int cap = WatchWire.maxPings, }) { final pings = []; @@ -80,12 +86,20 @@ class WatchGeoBuilder { for (var i = 0; i < txPings.length; i++) { final tx = txPings[i]; final success = tx.heardRepeaters.isNotEmpty; + final hasDirectEcho = + tx.heardRepeaters.any((repeater) => repeater.pathHops == null); + final hasMultiHopOnly = !hasDirectEcho && success; pings.add(WatchPing( id: 'tx-${tx.timestamp.millisecondsSinceEpoch}-$i', lat: tx.latitude, lon: tx.longitude, kind: 'tx', - color: pingColor('tx', success), + // The phone draws a multi-hop-only return as RX: it proves the packet + // came back through the mesh, but not that any repeater heard us + // directly. Keep the TX identity and mirror that evidence colour. + color: hasMultiHopOnly + ? pingColor('rx', true) + : pingColor('tx', success), at: tx.timestamp, )); } @@ -102,6 +116,30 @@ class WatchGeoBuilder { )); } + for (var i = 0; i < discLogEntries.length; i++) { + final entry = discLogEntries[i]; + pings.add(WatchPing( + id: 'disc-${entry.timestamp.millisecondsSinceEpoch}-$i', + lat: entry.latitude, + lon: entry.longitude, + kind: 'disc', + color: pingColor('disc', entry.discoveredNodes.isNotEmpty), + at: entry.timestamp, + )); + } + + for (var i = 0; i < traceLogEntries.length; i++) { + final entry = traceLogEntries[i]; + pings.add(WatchPing( + id: 'trace-${entry.timestamp.millisecondsSinceEpoch}-$i', + lat: entry.latitude, + lon: entry.longitude, + kind: 'trace', + color: pingColor('trace', entry.success), + at: entry.timestamp, + )); + } + pings.sort((a, b) => b.at.compareTo(a.at)); if (pings.length <= cap) return pings; return pings.sublist(0, cap); diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index c6980c4..e5822cb 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/models/log_entry.dart'; import 'package:mesh_mapper/models/ping_data.dart'; import 'package:mesh_mapper/models/repeater.dart'; import 'package:mesh_mapper/providers/app_state_provider.dart' show OverlayPingType; @@ -24,6 +25,31 @@ RxPing _rx(DateTime at) => RxPing( rssi: -90, ); +DiscLogEntry _disc(DateTime at, {required bool discovered}) => DiscLogEntry( + timestamp: at, + latitude: 47.62, + longitude: -122.32, + discoveredNodes: discovered + ? [ + DiscoveredNodeEntry( + repeaterId: '7a', + nodeType: 'REPEATER', + localSnr: 7, + localRssi: -88, + remoteSnr: 5, + ), + ] + : [], + ); + +TraceLogEntry _trace(DateTime at, {required bool success}) => TraceLogEntry( + timestamp: at, + latitude: 47.63, + longitude: -122.33, + targetRepeaterId: '8b', + success: success, + ); + Repeater _repeater({ required String id, required double lat, @@ -54,7 +80,12 @@ void main() { final tx = List.generate(40, (i) => _tx(base.add(Duration(minutes: i)))); final rx = List.generate(40, (i) => _rx(base.add(Duration(seconds: i)))); - final pings = WatchGeoBuilder.buildPings(txPings: tx, rxPings: rx); + final pings = WatchGeoBuilder.buildPings( + txPings: tx, + rxPings: rx, + discLogEntries: const [], + traceLogEntries: const [], + ); expect(pings.length, WatchWire.maxPings); for (var i = 1; i < pings.length; i++) { @@ -77,6 +108,8 @@ void main() { final pings = WatchGeoBuilder.buildPings( txPings: [answered, ignored], rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], ); final byTime = {for (final p in pings) p.at: p}; @@ -89,6 +122,181 @@ void main() { WatchColor.fromColor(PingColors.txFail), ); }); + + test('multi-hop-only TX is RX-coloured unless any echo is direct', () { + final multiHopOnly = _tx( + DateTime(2026, 8, 12, 10, 2), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + HeardRepeater( + repeaterId: '5f', + snr: 3, + pathHops: ['8b', '5f'], + ), + ], + ); + final includesDirect = _tx( + DateTime(2026, 8, 12, 10, 1), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + HeardRepeater(repeaterId: '5f', snr: 3), + ], + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: [multiHopOnly, includesDirect], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[multiHopOnly.timestamp]!.kind, 'tx'); + expect( + byTime[multiHopOnly.timestamp]!.color, + WatchColor.fromColor(PingColors.rx), + ); + expect( + byTime[includesDirect.timestamp]!.color, + WatchColor.fromColor(PingColors.txSuccess), + ); + }); + + test('discovery markers use response success and failure colours', () { + final answered = _disc( + DateTime(2026, 8, 12, 10, 1), + discovered: true, + ); + final unanswered = _disc( + DateTime(2026, 8, 12, 10), + discovered: false, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: [answered, unanswered], + traceLogEntries: const [], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[answered.timestamp]!.kind, 'disc'); + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.discSuccess), + ); + expect( + byTime[unanswered.timestamp]!.color, + WatchColor.fromColor(PingColors.discFail), + ); + }); + + test('trace markers use the trace result colours', () { + final answered = _trace( + DateTime(2026, 8, 12, 10, 1), + success: true, + ); + final unanswered = _trace( + DateTime(2026, 8, 12, 10), + success: false, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: [answered, unanswered], + ); + + final byTime = {for (final ping in pings) ping.at: ping}; + expect(byTime[answered.timestamp]!.kind, 'trace'); + expect( + byTime[answered.timestamp]!.color, + WatchColor.fromColor(PingColors.traceSuccess), + ); + expect( + byTime[unanswered.timestamp]!.color, + WatchColor.fromColor(PingColors.noResponse), + ); + }); + + test('the cap keeps the newest markers across mixed sources', () { + final base = DateTime(2026, 8, 12, 10); + final oldTx = List.generate( + 60, + (i) => _tx(base.subtract(Duration(minutes: i + 1))), + ); + final latestTx = _tx(base.add(const Duration(seconds: 1))); + final latestRx = _rx(base.add(const Duration(seconds: 2))); + final latestDisc = _disc( + base.add(const Duration(seconds: 3)), + discovered: true, + ); + final latestTrace = _trace( + base.add(const Duration(seconds: 4)), + success: true, + ); + + final pings = WatchGeoBuilder.buildPings( + txPings: [...oldTx, latestTx], + rxPings: [latestRx], + discLogEntries: [latestDisc], + traceLogEntries: [latestTrace], + cap: 4, + ); + + expect(pings.map((ping) => ping.kind), ['trace', 'disc', 'rx', 'tx']); + expect(pings.map((ping) => ping.at), [ + latestTrace.timestamp, + latestDisc.timestamp, + latestRx.timestamp, + latestTx.timestamp, + ]); + }); + + test('all four marker types survive a realistic mixed history', () { + final base = DateTime(2026, 8, 12, 10); + + final pings = WatchGeoBuilder.buildPings( + txPings: List.generate( + 12, + (i) => _tx(base.add(Duration(minutes: i * 4))), + ), + rxPings: List.generate( + 8, + (i) => _rx(base.add(Duration(minutes: i * 6 + 1))), + ), + discLogEntries: List.generate( + 6, + (i) => _disc( + base.add(Duration(minutes: i * 8 + 2)), + discovered: i.isEven, + ), + ), + traceLogEntries: List.generate( + 4, + (i) => _trace( + base.add(Duration(minutes: i * 12 + 3)), + success: i.isEven, + ), + ), + ); + + expect(pings.map((ping) => ping.kind).toSet(), { + 'tx', + 'rx', + 'disc', + 'trace', + }); + }); }); group('buildRepeaters', () { From 6a8ba00731d4de53c77b724bbf478aea84116fc8 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 11:38:14 -0700 Subject: [PATCH 25/71] Stop drawing a map at a wrist nobody is looking at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 71-minute walk cost ~40% of Adam's watch battery — about 34%/hour, which makes the app useless for the long drive it exists for. Two patterns account for the obvious waste, and neither was measured here: watch power cannot be instrumented from this machine, so these are the known-expensive things removed, not a proven culprit. A real walk is the only test. **Always-On was unhandled.** Phase 7 planned it and it was never built, so for most of that walk — wrist down, app frontmost — watchOS was rendering a live MapKit view with annotations. MapKit is the most power-hungry thing on the device, and none of it is legible at reduced luminance. The dimmed state now removes that subtree entirely rather than covering it, and stops driving the camera: no recentring, no corrections, no animations until full luminance returns. **A 1 Hz TimelineView redrew the panel over the live map** for the whole session, compositing translucent material every second. Most of it bought nothing: `Text(timerInterval:)` already updates itself natively, and a depleting bar can be one linear animation over the remaining phase rather than thousands of view updates. The Live Activity's bar had the same timeline and the same fix. No 1 Hz timeline remains anywhere. Always-On also needed its own layout, which only became apparent once it could be seen. Reusing the map's overlay panel left a small card pinned to the bottom of a black screen at map-overlay type size — and then the phase title truncated to "List…" on a 40 mm, hiding the one thing a dimmed glance is for. The dimmed view now spends the space it actually has: title at 18 pt wrapping rather than truncating, countdown at 24 pt beneath it, Top Heard full width below. The progress bar is gone from that state — beside an explicit countdown it was duplicated information and another compositing pass. The countdown reads "<1 min" / "3 min" there, because Always-On updates about once a minute and a seconds figure would be silently up to a minute wrong. `MeshMapperForceDimmed` is kept, not scaffolding: Always-On cannot be entered in the simulator and otherwise needs a wrist-down device, so without it this surface goes back to being unreviewable — which is how it shipped bottom-pinned and truncated in the first place. --- .../MeshMapperLiveActivity.swift | 187 ++++++--- ios/MeshMapperWatch/MapPage.swift | 368 ++++++++++++++---- 2 files changed, 426 insertions(+), 129 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index c3dc215..b2bf840 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -199,43 +199,98 @@ private struct MeshMapperPhaseBar: View { let countdownFont: Font let countdownWidth: CGFloat + @State private var remainingFraction: CGFloat + @State private var deadlineLapsed: Bool + + init( + state: MeshMapperActivityAttributes.ContentState, + height: CGFloat, + titleFont: Font, + countdownFont: Font, + countdownWidth: CGFloat + ) { + self.state = state + self.height = height + self.titleFont = titleFont + self.countdownFont = countdownFont + self.countdownWidth = countdownWidth + + let now = Date() + _remainingFraction = State( + initialValue: state.phaseRemainingFraction(at: now) ?? 0 + ) + _deadlineLapsed = State( + initialValue: state.phaseEndsAt.map { $0 <= now } ?? false + ) + } + var body: some View { - TimelineView(.periodic(from: .now, by: 1)) { context in - GeometryReader { geometry in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - Capsule() - // Progress says how much time remains. Outcome has quieter, - // dedicated dots elsewhere and must not recolour the whole track. - .fill(MeshMapperPalette.accent) - .frame( - width: geometry.size.width - * (state.phaseRemainingFraction(at: context.date) ?? 0) - ) - } - .overlay { - HStack { - Text(state.phaseTitle) - .font(titleFont) - .foregroundStyle( - .white.opacity(state.deadlineLapsed(at: context.date) ? 0.45 : 1) - ) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 4) - MeshMapperCountdown( - state: state, - at: context.date, - font: countdownFont - ) - .frame(width: countdownWidth, alignment: .trailing) - } - .padding(.horizontal, 7) - .shadow(color: .black.opacity(0.7), radius: 1.5) + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + // Progress says how much time remains. Outcome has quieter, + // dedicated dots elsewhere and must not recolour the whole track. + .fill(MeshMapperPalette.accent) + .frame(width: geometry.size.width * remainingFraction) + } + .overlay { + HStack { + Text(state.phaseTitle) + .font(titleFont) + .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + MeshMapperCountdown( + state: state, + isActive: !deadlineLapsed, + font: countdownFont + ) + .frame(width: countdownWidth, alignment: .trailing) } + .padding(.horizontal, 7) + .shadow(color: .black.opacity(0.7), radius: 1.5) } } .frame(height: height) + .task(id: state.phaseAnimationKey) { + await runPhaseAnimation() + } + } + + @MainActor + private func runPhaseAnimation() async { + let now = Date() + let fraction = state.phaseRemainingFraction(at: now) ?? 0 + let lapsed = state.phaseEndsAt.map { $0 <= now } ?? false + + // ActivityKit may replace state midway through a phase. Snap to the + // absolute fraction before starting one compositor animation, so no timer + // tick or stale previous endpoint can distort the new bar. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + remainingFraction = fraction + deadlineLapsed = lapsed + } + + guard let endsAt = state.phaseEndsAt else { return } + let remaining = endsAt.timeIntervalSince(now) + guard remaining > 0 else { return } + + await Task.yield() + withAnimation(.linear(duration: remaining)) { + remainingFraction = 0 + } + + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + deadlineLapsed = true } } @@ -427,12 +482,22 @@ private struct MeshMapperOutcomeDot: View { private struct MeshMapperCompactTrailing: View { let state: MeshMapperActivityAttributes.ContentState + @State private var deadlineLapsed: Bool + + init(state: MeshMapperActivityAttributes.ContentState) { + self.state = state + let now = Date() + _deadlineLapsed = State( + initialValue: state.phaseEndsAt.map { $0 <= now } ?? false + ) + } + var body: some View { - TimelineView(.periodic(from: .now, by: 1)) { context in - if state.hasActiveCountdown(at: context.date) { + Group { + if !deadlineLapsed, state.activeCountdownRange != nil { MeshMapperCountdown( state: state, - at: context.date, + isActive: true, font: .caption2.monospacedDigit().weight(.bold) ) .frame(minWidth: 28) @@ -445,17 +510,31 @@ private struct MeshMapperCompactTrailing: View { MeshMapperOutcomeDot(state: state, diameter: 8) } } + .task(id: state.phaseAnimationKey) { + let now = Date() + deadlineLapsed = state.phaseEndsAt.map { $0 <= now } ?? false + guard let endsAt = state.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 MeshMapperCountdown: View { let state: MeshMapperActivityAttributes.ContentState - let at: Date + let isActive: Bool let font: Font var body: some View { - if let end = state.phaseEndsAt, end > at { - Text(timerInterval: at...end, countsDown: true, showsHours: false) + if isActive, let range = state.activeCountdownRange { + Text(timerInterval: range, countsDown: true, showsHours: false) .font(font) .lineLimit(1) .accessibilityLabel("Time remaining") @@ -468,7 +547,29 @@ private enum MeshMapperPalette { static let accent = Color.accentColor } +private struct MeshMapperPhaseAnimationKey: Hashable { + let phase: String + let title: String + let endsAt: Date? + let durationMs: Int? +} + extension MeshMapperActivityAttributes.ContentState { + fileprivate var phaseAnimationKey: MeshMapperPhaseAnimationKey { + MeshMapperPhaseAnimationKey( + phase: phase, + title: phaseTitle, + endsAt: phaseEndsAt, + durationMs: phaseDurationMs + ) + } + + fileprivate var activeCountdownRange: ClosedRange? { + let now = Date() + guard let phaseEndsAt, phaseEndsAt > now else { return nil } + return now...phaseEndsAt + } + fileprivate var connectionLabel: String { isConnected ? "Connected" : "Disconnected" } @@ -537,16 +638,6 @@ extension MeshMapperActivityAttributes.ContentState { } } - fileprivate func hasActiveCountdown(at date: Date) -> Bool { - guard let phaseEndsAt else { return false } - return phaseEndsAt > date - } - - fileprivate func deadlineLapsed(at date: Date) -> Bool { - guard let phaseEndsAt else { return false } - return phaseEndsAt <= date - } - /// Fraction remaining in the current countdown, calculated locally so the /// Live Activity does not need a state update every second. fileprivate func phaseRemainingFraction(at date: Date) -> CGFloat? { diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 57be655..a31aec0 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -12,6 +12,17 @@ import WatchKit struct MapPage: View { @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 UserDefaults.standard.bool(forKey: "MeshMapperForceDimmed") { return true } + #endif + return environmentLuminanceReduced + } @State private var camera: MapCameraPosition = .automatic @@ -136,32 +147,17 @@ struct MapPage: View { private func content(_ proxy: MapProxy) -> some View { ZStack { - map(proxy) - - // One panel carrying phase and Top Heard. - // - // Earlier versions floated the countdown in the opposite corner and drew - // full-width into the safe areas. On real hardware both got clipped by - // the display curvature — the simulator renders a flat rectangle and - // never shows it. This panel enters only the bottom safe area, and only - // after the measured curvature has bought enough horizontal clearance. - VStack(spacing: 0) { - HStack { - Spacer(minLength: 0) - recenterButton(proxy) - } - Spacer(minLength: 0) - statusPanel - .background( - GeometryReader { geo in - Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) - } - ) + if isLuminanceReduced { + // Always-On spends most of a long session with the wrist down. A live + // MapKit renderer and its annotations buy no useful glance information + // at reduced luminance, so remove that subtree rather than merely + // covering it. + Color.black.ignoresSafeArea() + dimmedStatus + } else { + map(proxy) + mapOverlay(proxy) } - .padding(.horizontal, panelHorizontalInset) - .padding(.top, 2) - .padding(.bottom, curvedPanelHorizontalInset == nil ? 0 : panelBottomGap) - .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) } .background( GeometryReader { geo in @@ -197,6 +193,12 @@ struct MapPage: View { .onChange(of: followSuspendedUntil) { _, until in if until == nil { recenterIfFollowing(proxy) } } + .onChange(of: isLuminanceReduced) { _, reduced in + // Camera work is forbidden while dimmed. Returning to full luminance is + // itself a state change, so following can resume immediately even if the + // phone has not produced another GPS fix yet. + if !reduced { recenterIfFollowing(proxy) } + } .onAppear { recenterIfFollowing(proxy) #if DEBUG @@ -209,6 +211,140 @@ struct MapPage: View { } } + /// Map chrome remains an overlay so its measured frame can place the fix in + /// the visible band above it. Always-On has its own hierarchy and therefore + /// cannot accidentally inherit this bottom-pinned card again. + private func mapOverlay(_ proxy: MapProxy) -> some View { + VStack(spacing: 0) { + HStack { + Spacer(minLength: 0) + recenterButton(proxy) + } + Spacer(minLength: 0) + statusPanel + .background( + GeometryReader { geo in + Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) + } + ) + } + // 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 for Always-On, 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 dimmedStatus: some View { + VStack(alignment: .leading, spacing: 0) { + if let snapshot { + dimmedPhase(snapshot) + } 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 + dimmedHeardRow(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. Always-On changes in discrete steps; it never interpolates. + .transaction { $0.animation = nil } + } + + /// The two readings an Always-On glance exists to answer, each with its own + /// line instead of competing inside the map overlay's narrow bar. + /// + /// A seconds figure can be nearly a minute wrong while watchOS throttles an + /// Always-On screen. Showing whole minutes (or “<1 min”) makes that cadence + /// honest, while the isolated minute schedule avoids waking the rest of the + /// hierarchy. A static progress fill would repeat that number less precisely + /// and spend both pixels and compositing work, so the dimmed surface omits it. + private func dimmedPhase(_ snapshot: WatchSnapshot) -> some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + let lapsed = snapshot.phaseEndsAt.map { $0 <= context.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 let countdown = dimmedCountdown(snapshot, at: context.date) { + Text(countdown) + .font(.system(size: 24, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private func dimmedCountdown(_ snapshot: WatchSnapshot, at date: Date) -> String? { + guard let endsAt = snapshot.phaseEndsAt else { return nil } + let remaining = endsAt.timeIntervalSince(date) + guard remaining > 0 else { return nil } + if remaining < 60 { return "<1 min" } + return "\(Int(ceil(remaining / 60))) min" + } + + /// 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 dimmedHeardRow(_ 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 @@ -252,9 +388,8 @@ struct MapPage: View { .padding(.horizontal, 8) .padding(.vertical, 5) .frame(maxWidth: .infinity, alignment: .leading) - // Blurred material rather than flat translucency: a 70% black panel - // lets bright basemap labels bleed through and fight the SNR digits. - // Blurring the map behind the panel removes the competing detail. + // 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) @@ -327,61 +462,11 @@ struct MapPage: View { .foregroundStyle(.orange) .lineLimit(1) } else if let snapshot { - TimelineView(.periodic(from: .now, by: 1)) { context in - track(snapshot, at: context.date) - .overlay { - HStack { - phaseTitle(snapshot, at: context.date) - Spacer(minLength: 4) - countdown(snapshot, at: context.date) - } - // 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) - } - } + WatchPhaseBar(snapshot: snapshot) .frame(height: 15) } } - /// The depleting track. Greedy on purpose: the labels overlay it, leaving the - /// whole panel width available to show phase progress. - private func track(_ snapshot: WatchSnapshot, at date: Date) -> some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - Capsule() - .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) - .frame(width: geo.size.width * (snapshot.phaseRemainingFraction(at: date) ?? 0)) - } - } - } - - private func phaseTitle(_ snapshot: WatchSnapshot, at date: Date) -> some View { - // A missing deadline describes a durable state. A passed one describes - // only what the phone last reported, so dimming avoids presenting it as a - // live claim while still preserving the useful last-known phase. - let deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= date } ?? false - return Text(snapshot.phaseTitle) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) - .lineLimit(1) - .truncationMode(.tail) - } - - /// Fixed width because `Text(timerInterval:)` otherwise reserves room for the - /// widest value it might ever show, which would starve the track. - @ViewBuilder - private func countdown(_ snapshot: WatchSnapshot, at date: Date) -> some View { - if let endsAt = snapshot.phaseEndsAt, endsAt > date { - Text(timerInterval: date...endsAt, countsDown: true) - .font(.system(size: 11, weight: .bold).monospacedDigit()) - .foregroundStyle(.white) - .frame(width: 38, alignment: .trailing) - } - } - /// Largest size allowed by the existing hash-length ladder. Width fitting /// may make it smaller, but never larger than the familiar phone treatment. private var rowFontSizeCap: CGFloat { @@ -509,7 +594,7 @@ struct MapPage: View { // MARK: - Camera private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { - guard force || isFollowing, let fix else { return } + guard !isLuminanceReduced, force || isFollowing, let fix else { return } let center = centerPlacing(fix, proxy: proxy) let isInitialPlacement = programmaticCenter == nil programmaticCenter = center @@ -563,7 +648,7 @@ struct MapPage: View { /// The deadband is what stops it: each pass lands within a couple of points, /// the next sees no error worth fixing, and it settles. private func correctPlacement(_ proxy: MapProxy) { - guard isFollowing, let fix, let targetPoint, + guard !isLuminanceReduced, isFollowing, let fix, let targetPoint, let point = proxy.convert(fix, to: .global) else { return } guard abs(point.y - targetPoint.y) > 6 else { return } @@ -668,6 +753,127 @@ struct MapPage: View { } } +/// 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 animated to zero by the +/// compositor; 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 + + @State private var remainingFraction: CGFloat + @State private var deadlineLapsed: Bool + + init(snapshot: WatchSnapshot) { + self.snapshot = snapshot + let now = Date() + _remainingFraction = State( + initialValue: CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0) + ) + _deadlineLapsed = State( + initialValue: snapshot.phaseEndsAt.map { $0 <= now } ?? false + ) + } + + private var phaseKey: PhaseAnimationKey { + PhaseAnimationKey( + phase: snapshot.phase, + title: snapshot.phaseTitle, + endsAtMs: snapshot.phaseEndsAtMs, + durationMs: snapshot.phaseDurationMs + ) + } + + /// 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 { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(.white.opacity(0.16)) + Capsule() + .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) + .frame(width: geo.size.width * remainingFraction) + } + .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. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + remainingFraction = fraction + deadlineLapsed = lapsed + } + + guard let endsAt = snapshot.phaseEndsAt else { return } + let remaining = endsAt.timeIntervalSince(now) + guard remaining > 0 else { return } + + await Task.yield() + withAnimation(.linear(duration: remaining)) { + remainingFraction = 0 + } + + do { + try await Task.sleep(for: .seconds(remaining)) + } catch { + return + } + guard !Task.isCancelled else { return } + deadlineLapsed = true + } + + private struct PhaseAnimationKey: Hashable { + let phase: String + let title: String + let endsAtMs: Double? + let durationMs: Int? + } +} + extension Comparable { fileprivate func clamped(to limits: ClosedRange) -> Self { min(max(self, limits.lowerBound), limits.upperBound) From ad826e7760ee249ff60dcd55da8e604edbed832a Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 12:11:44 -0700 Subject: [PATCH 26/71] Key the drain to its deadline, not its caption; widen the panel to be glanceable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things from real-device use. **The bar stuttered in "ping skipped" mode.** `phaseKey` included the phase and its title, and `.task(id:)` restarted the drain whenever either changed. In skip mode the phase flips between waiting/"Next ping" and skipped/"Ping skipped" while the *same* auto-ping timer runs to an unchanged deadline — so each flip cancelled the animation, snapped the fill back to its true fraction and started again. The bar was reporting a change of wording as a change in time. It keys on the deadline and duration alone now; the title is a caption over the drain, not part of it. **A bar that cannot be refreshed should not pretend to move.** On the iPhone's always-on lock screen, refreshing about once a minute, the animated fill rendered frozen mid-drain — which reads as a stalled session, worse than showing nothing. At reduced luminance the Live Activity now draws the track alone and lets the countdown carry the state, a coarse number being honest where a stopped bar is not. Adam's rule, worth keeping: an element implying continuous motion must not be drawn where the refresh rate cannot deliver it. **The panel's text was too small to glance at.** Raising it buys width, because the curvature clearance needed at a given height falls as the panel moves up the curve, and width buys type size through the existing solver. This re-couples placement to clearance, which `bf6f7ef` deliberately decoupled — that was right when he wanted the panel narrower, and this is right now that he wants it legible. His call both times. 46 mm gap 18.9 inset 8.3 panel 191 font 11.0 45 mm gap 18.4 inset 8.2 panel 182 font 11.0 40 mm gap 10.0 inset 6.3 panel 149 font 8.7 The ladder cap rose a point too, since width alone was no longer the binding constraint. Mirroring the phone's `RepeaterIdChip` sizes was the original reason for it, but a watch is read at arm's length in motion. Worth flagging: the panel is now nearly as wide as it was before he asked for it narrowed, though sitting much higher. The clearance arithmetic says that is safe — at 19 pt up the curve the corner needs only ~4 pt of inset — but the hardware confirmation was taken at the previous geometry, so this specific combination is unverified on glass. --- .../MeshMapperLiveActivity.swift | 41 +++++++++---- ios/MeshMapperWatch/MapPage.swift | 57 +++++++++---------- 2 files changed, 56 insertions(+), 42 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index b2bf840..65e2694 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -193,6 +193,8 @@ private struct MeshMapperSmallActivityContent: View { /// updates. The title and countdown ride over the fill so neither consumes /// track width, and their shadow keeps them legible on both halves. private struct MeshMapperPhaseBar: View { + @Environment(\.isLuminanceReduced) private var isLuminanceReduced + let state: MeshMapperActivityAttributes.ContentState let height: CGFloat let titleFont: Font @@ -228,11 +230,13 @@ private struct MeshMapperPhaseBar: View { GeometryReader { geometry in ZStack(alignment: .leading) { Capsule().fill(.white.opacity(0.16)) - Capsule() - // Progress says how much time remains. Outcome has quieter, - // dedicated dots elsewhere and must not recolour the whole track. - .fill(MeshMapperPalette.accent) - .frame(width: geometry.size.width * remainingFraction) + if !isLuminanceReduced { + Capsule() + // Progress says how much time remains. Outcome has quieter, + // dedicated dots elsewhere and must not recolour the whole track. + .fill(MeshMapperPalette.accent) + .frame(width: geometry.size.width * remainingFraction) + } } .overlay { HStack { @@ -254,13 +258,24 @@ private struct MeshMapperPhaseBar: View { } } .frame(height: height) - .task(id: state.phaseAnimationKey) { - await runPhaseAnimation() + // An element that implies continuous motion must not be drawn where the + // refresh rate cannot deliver it. Luminance participates only to stop and + // resume rendering; among payload fields, deadline and duration alone + // identify the drain, so a caption change cannot restart it. + .task(id: animationTaskKey) { + await runPhaseAnimation(animateFill: !isLuminanceReduced) } } + private var animationTaskKey: MeshMapperPhaseAnimationTaskKey { + MeshMapperPhaseAnimationTaskKey( + drain: state.phaseAnimationKey, + canRenderContinuously: !isLuminanceReduced + ) + } + @MainActor - private func runPhaseAnimation() async { + private func runPhaseAnimation(animateFill: Bool) async { let now = Date() let fraction = state.phaseRemainingFraction(at: now) ?? 0 let lapsed = state.phaseEndsAt.map { $0 <= now } ?? false @@ -275,6 +290,7 @@ private struct MeshMapperPhaseBar: View { deadlineLapsed = lapsed } + guard animateFill else { return } guard let endsAt = state.phaseEndsAt else { return } let remaining = endsAt.timeIntervalSince(now) guard remaining > 0 else { return } @@ -548,17 +564,18 @@ private enum MeshMapperPalette { } private struct MeshMapperPhaseAnimationKey: Hashable { - let phase: String - let title: String let endsAt: Date? let durationMs: Int? } +private struct MeshMapperPhaseAnimationTaskKey: Hashable { + let drain: MeshMapperPhaseAnimationKey + let canRenderContinuously: Bool +} + extension MeshMapperActivityAttributes.ContentState { fileprivate var phaseAnimationKey: MeshMapperPhaseAnimationKey { MeshMapperPhaseAnimationKey( - phase: phase, - title: phaseTitle, endsAt: phaseEndsAt, durationMs: phaseDurationMs ) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index a31aec0..b4419eb 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -80,17 +80,12 @@ struct MapPage: View { return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) } - /// Both gaps scale with the estimated corner radius, putting every watch at - /// the same relative positions on its curve. The 4/19 and 8/19 ratios are - /// calibrated from the 40 mm watch Adam signed off: at R=19 they reproduce - /// its current clearance and placement by construction. Changing either - /// constant therefore changes the one hardware size already known-good. - private static let curveClearanceGapRatio: CGFloat = 4.0 / 19.0 - private static let panelBottomGapRatio: CGFloat = 8.0 / 19.0 - - private var curveClearanceGap: CGFloat { - bottomSafeAreaInset * Self.curveClearanceGapRatio - } + /// 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 var panelBottomGap: CGFloat { bottomSafeAreaInset * Self.panelBottomGapRatio @@ -107,14 +102,14 @@ struct MapPage: View { /// 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 scaled clearance 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 by this equation. + /// 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 = curveClearanceGap + 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 } @@ -467,13 +462,15 @@ struct MapPage: View { } } - /// Largest size allowed by the existing hash-length ladder. Width fitting - /// may make it smaller, but never larger than the familiar phone treatment. + /// 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 9 } - if widest > 2 { return 10 } - return 11 + if widest > 4 { return 10 } + if widest > 2 { return 11 } + return 12 } /// Font size that leaves two intrinsic rows safely inside their columns. @@ -487,13 +484,14 @@ struct MapPage: View { return (columnWidth - 12) / (0.62 * idChars + 3.1) } - /// Seven points is the floor at which two columns remain readable. Falling - /// below it is the one reason to spend the extra height on a single column. - private var twoHeardColumnsFit: Bool { unconstrainedRowFontSize >= 7 } + /// 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: 7...11), rowFontSizeCap) + return min(unconstrainedRowFontSize.clamped(to: 8...12), rowFontSizeCap) } private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @@ -778,8 +776,6 @@ private struct WatchPhaseBar: View { private var phaseKey: PhaseAnimationKey { PhaseAnimationKey( - phase: snapshot.phase, - title: snapshot.phaseTitle, endsAtMs: snapshot.phaseEndsAtMs, durationMs: snapshot.phaseDurationMs ) @@ -867,8 +863,9 @@ private struct WatchPhaseBar: View { } private struct PhaseAnimationKey: Hashable { - let phase: String - let title: String + // 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? } From ac862b7dbe79833e6ccf25506d06c790729f33ba Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 12:18:53 -0700 Subject: [PATCH 27/71] Keep the fix still and move the world under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "On the map our location doesn't [stay] fixed in the center with the map moving around, instead we move on the map and then recenter." Two decisions collided. The phone withholds geo updates until the fix moves 15 m, so nothing moves between snapshots and each one lands as a single large step. And the camera animated over 0.25 s while the fix annotation — anchored to a coordinate — moved the instant the snapshot arrived. So the puck jumped ahead and the map slid after it, which is precisely the sensation of moving across the map and being chased. Worst where he noticed it: at walking pace that is one lurch every ~11 s. At 30 mph the same threshold fires every ~1.2 s and reads as continuous. Automatic follow updates and placement corrections now cut, so the camera moves in the same frame as the fix and the puck stays where it is while the world steps beneath it. An explicit recentre tap still animates: it is rare, the wearer asked for it, and the motion shows what their tap did. Nobody asked for a follow update, so nothing should appear to move except the world. Two alternatives were considered and deliberately not taken, both recorded: lowering the 15 m threshold buys smaller steps with more radio wakeups, and interpolating between fixes would pan continuously but invents position data and reinstates the continuous animation `f15c93e` removed. Either is available if stepping still distracts on a drive — after tomorrow's battery numbers, not before. --- ios/MeshMapperWatch/MapPage.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index b4419eb..07f123f 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -594,18 +594,21 @@ struct MapPage: View { private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { guard !isLuminanceReduced, force || isFollowing, let fix else { return } let center = centerPlacing(fix, proxy: proxy) - let isInitialPlacement = programmaticCenter == nil programmaticCenter = center let region = MKCoordinateRegion(center: center, span: currentSpan) - if isInitialPlacement { - // Cutting to the first fix centres and zooms as one operation. Animating - // from `.automatic` would fly through unrelated tiles from MapKit's - // arbitrary opening location before reaching the wearer. - camera = .region(region) - } else { + + if force { + // A tap is a rare, explicit request to move the map, so animation shows + // the wearer what their action changed. Automatic follow is different: + // the fix coordinate has already changed in this frame, and animating + // the camera after it makes the puck wander before the map catches up. withAnimation(.easeInOut(duration: 0.25)) { camera = .region(region) } + } else { + // First placement, GPS steps and placement corrections all cut so the + // puck stays visually fixed while the world moves beneath it. + camera = .region(region) } } From e8027d68808d60058e0558987eee851902a2128c Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 12:32:47 -0700 Subject: [PATCH 28/71] Anchor the Live Activity countdown to the end of its bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "The countdown timer in the live [activity] is not positioned at the end of the bar. It would be nice to move it a bit closer to the end of the bar as it is in the watch app." Measured from a render rather than guessed: the number's right edge sat 8.7 pt inside the track on the lock screen and 8.3 in the island. Because the cap is rounded, the eye measures to the curve, which makes that gap read as larger than it is — the number looked adrift in the dark part of the track rather than anchored to its end. The two ends were being inset equally, but they are not symmetric in effect: the title begins against a straight fill edge while the countdown ends against a curve. The inset is asymmetric now, and the gap measures 4.0 pt on the lock screen, 3.7 in the island and 4.0 on the small family. The spacer between title and countdown is untouched — it is what guarantees the title truncates before the two can collide, so buying room from it would trade one defect for a worse one. --- ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 65e2694..ca4bb43 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -253,7 +253,13 @@ private struct MeshMapperPhaseBar: View { ) .frame(width: countdownWidth, alignment: .trailing) } - .padding(.horizontal, 7) + // The native timer keeps a small amount of reserved space beyond the + // visible glyphs even inside its trailing-aligned fixed frame. Leaving + // the title at seven points but tucking that reservation toward the + // rounded cap puts the visible digits about four points from the end; + // 2.5 points still clears the curve on the shortest 18 pt track. + .padding(.leading, 7) + .padding(.trailing, 2.5) .shadow(color: .black.opacity(0.7), radius: 1.5) } } From bce27b199514e1036cd1566637cb97c1441f9edd Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 13:21:53 -0700 Subject: [PATCH 29/71] Stop rebuilding the watch payload twice a second, and fix repeater identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the branch review, all traced before being believed. **A synthetic timestamp defeated the dedupe and lied on screen.** Every heard row carried `at: now` — the moment the payload was built — so once Top Heard held anything, each rebuild produced a different fingerprint and the 2 s throttle became the only brake: roughly 2,100 context updates across a 71-minute session where near-zero were intended. Excluding `updatedAtMs` from the fingerprint had achieved nothing. It was also displayed: Node Detail's "Heard" time read as now, always. Rows now carry when their set last changed, tracked separately for Top Heard and the RX slot because multi-hop updates can move one without moving the other. The manual-cooldown deadline serialises the timer's own `endTime` rather than being reconstructed from two `DateTime.now()` reads whose jitter alone changed the fingerprint. **The cheap check ran after the expensive one.** `_flush` built the whole geo payload — merging and sorting up to 2,000 ping candidates, resolving colours and distances, evaluating the twelve-condition ping gate — then serialised it, and only then compared the fingerprint and usually threw it away. Five countdown timers tick at 500 ms into the scheduler, so a quiet session did that twice a second. Urgency is now decided from a small scalar projection first, and a flush inside the throttle window reschedules without building anything. Worse, none of it was gated on owning a watch. `isSupportedPlatform` only asked whether this was iOS; native refused the payload at `isPaired` / `isWatchAppInstalled`, but after Dart had built, encoded and crossed the method channel. Someone with no Apple Watch paid all of that for a payload that was discarded. Native now publishes its availability and the scheduler does nothing without it — and because `sessionWatchStateDidChange` republishes, a watch paired after launch starts working without a restart. **Links compared incompatible identities, so none had ever drawn.** `linkedRepeaterIds` and the heard IDs carry path hashes; `WatchRepeater.id` carried the API database ID and `hexId` never reached the wire. The watch compared the two exactly, so no link line has ever appeared and the current-cycle ring almost never fired. Both identities now travel, and matching resolves a path hash as a unique hex prefix — ambiguous prefixes draw nothing, because a line to the wrong repeater asserts a relationship that does not exist. The RX slot joins the highlight set, which was the item deferred from the ping fix. Nine tests cover the parts that were blind: timestamp stability across rebuilds, throttling before the build, pairing transitions, prefix links, ambiguity, and RX-only highlighting. --- ios/MeshMapperWatch/MapPage.swift | 25 +- ios/MeshMapperWatch/SampleSnapshot.swift | 1 + ios/Runner/WatchSessionManager.swift | 13 ++ ios/Shared/MeshMapperWatchPayload.swift | 6 + lib/providers/app_state_provider.dart | 52 ++++- lib/services/watch/watch_bridge_service.dart | 92 +++++++- lib/services/watch/watch_geo_builder.dart | 58 ++++- lib/services/watch/watch_models.dart | 42 +++- .../watch/watch_geo_builder_test.dart | 69 +++++- .../watch/watch_wire_contract_test.dart | 213 +++++++++++++++++- 10 files changed, 522 insertions(+), 49 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 07f123f..e37fad2 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -533,8 +533,7 @@ struct MapPage: View { @MapContentBuilder private var linkLines: some MapContent { if settings.showLinks, let fix, let snapshot { - let linked = Set(snapshot.geo.linkedRepeaterIds) - ForEach(snapshot.geo.repeaters.filter { linked.contains($0.id) }) { repeater in + ForEach(linkedRepeaters(in: snapshot.geo)) { repeater in MapPolyline(coordinates: [ fix, CLLocationCoordinate2D(latitude: repeater.lat, longitude: repeater.lon), @@ -544,6 +543,28 @@ struct MapPage: View { } } + /// 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 { diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 230fd94..474890f 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -104,6 +104,7 @@ enum SampleSnapshot { ].map { id, name, dLat, dLon, color, heard in WatchRepeater( id: id, + hexId: id, name: name, lat: originLat + dLat, lon: originLon + dLon, diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index 2cf0a80..bae3aab 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -139,6 +139,17 @@ final class WatchSessionManager: NSObject { ] } + /// 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, @@ -189,6 +200,7 @@ extension WatchSessionManager: WCSessionDelegate { if let error { NSLog("[WATCH] Activation failed: \(error.localizedDescription)") } + publishStatus() } func sessionDidBecomeInactive(_ session: WCSession) {} @@ -201,6 +213,7 @@ extension WatchSessionManager: WCSessionDelegate { func sessionWatchStateDidChange(_ session: WCSession) { // A newly installed or newly paired watch has no context yet. lastContextData = nil + publishStatus() } func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 04a1e7e..f7dd927 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -69,6 +69,12 @@ struct WatchPing: Codable, Hashable, Identifiable { /// 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 diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index ad7f1f7..0f72ee6 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -244,6 +244,7 @@ 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; @@ -630,6 +631,7 @@ 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( @@ -676,6 +678,7 @@ 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; @@ -1213,8 +1216,31 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } void _scheduleWatchSync({bool immediate = false}) { - if (_isDisposed || !_watchBridge.isSupportedPlatform) return; - _watchBridge.schedule(_buildWatchSnapshot, immediate: immediate); + if (_isDisposed || !_watchBridge.canSync) return; + _watchBridge.schedule( + _buildWatchSnapshot, + urgencyKeyBuilder: _buildWatchUrgencyKey, + immediate: immediate, + ); + } + + /// 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 = _resolveLiveActivityPhase(); + 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, + ); } /// Builds the watch payload. @@ -1258,7 +1284,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchSnapshot( core: core, - geo: _buildWatchGeo(now), + geo: _buildWatchGeo(), controls: _buildWatchControls(), pingColor: pingColor, cue: _watchCue, @@ -1289,7 +1315,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; } - WatchGeo _buildWatchGeo(DateTime now) { + WatchGeo _buildWatchGeo() { final position = _resolveWatchPosition(); // Repeaters heard during the current cycle get the highlight ring. @@ -1302,6 +1328,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // can never disagree. final top = _topRepeatersOverlay; final rxSlot = _rxOverlaySlot; + if (rxSlot != null) heardIds.add(rxSlot.repeaterId.toUpperCase()); // Overlay IDs are hex path hashes, so resolve names by prefix at whatever // length this zone actually uses. @@ -1330,13 +1357,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { top: top, rxSlot: rxSlot, repeaterByHex: repeaterByHex, - at: now, + topAt: _topRepeatersOverlayUpdatedAt, + rxAt: _liveActivityRxUpdatedAt, lat: position?.lat, lon: position?.lon, ), linkedRepeaterIds: [ - for (final entry in top) entry.repeaterId, - if (rxSlot != null) rxSlot.repeaterId, + ...WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: _repeaters, + prefixes: heardIds, + ).keys, ], ); } @@ -1460,7 +1490,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { canManualPing: manualPing.allowed, isSessionActive: _autoPingEnabled, manualCooldownEndsAt: cooldownMs > 0 - ? DateTime.now().add(Duration(milliseconds: cooldownMs)) + ? _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 @@ -1985,6 +2015,12 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _watchBridge.attachCommandHandler( _handleWatchCommand, onRefusal: _emitWatchFailure, + // 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); + }, ); } diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 8ed9481..6d5ca20 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -8,6 +8,7 @@ import '../../utils/debug_logger_io.dart'; import 'watch_models.dart'; typedef WatchSnapshotBuilder = WatchSnapshot? Function(); +typedef WatchUrgencyKeyBuilder = String Function(); /// Decides whether a wrist command may begin. Returns null when admitted, or a /// reason when refused; admitted work continues independently of this reply. @@ -15,6 +16,7 @@ typedef WatchSnapshotBuilder = WatchSnapshot? Function(); /// bridge fakes source-compatible without putting the real path behind a wait. typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); typedef WatchCommandRefusalHandler = void Function(String reason); +typedef WatchAvailabilityHandler = void Function(bool available); /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// @@ -37,14 +39,18 @@ class WatchBridgeService { Timer? _scheduledUpdate; WatchSnapshotBuilder? _pendingSnapshotBuilder; + WatchUrgencyKeyBuilder? _pendingUrgencyKeyBuilder; WatchCommandHandler? _commandHandler; WatchCommandRefusalHandler? _commandRefusalHandler; + WatchAvailabilityHandler? _availabilityHandler; String? _lastPayload; String? _lastUrgencyKey; DateTime? _lastSentAt; + DateTime? _lastBuiltAt; bool _disposed = false; bool _didReconcileNativeState = false; + bool _canSync = false; Future _operationChain = Future.value(); /// Commands already handled, so redelivery can't fire a second transmit. @@ -52,19 +58,27 @@ class WatchBridgeService { bool get isSupportedPlatform => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; + bool get canSync => isSupportedPlatform && _canSync; /// Wire up the inbound command path. Safe to call more than once. void attachCommandHandler( WatchCommandHandler handler, { WatchCommandRefusalHandler? onRefusal, + WatchAvailabilityHandler? onAvailabilityChanged, }) { _commandHandler = handler; _commandRefusalHandler = onRefusal; + _availabilityHandler = onAvailabilityChanged; if (!isSupportedPlatform) return; _channel.setMethodCallHandler(_handleNativeCall); + unawaited(_refreshAvailability()); } Future _handleNativeCall(MethodCall call) async { + if (call.method == 'availabilityChanged') { + _applyAvailability(call.arguments, refreshNativeState: true); + return null; + } if (call.method != 'command') return null; final args = call.arguments; @@ -131,6 +145,40 @@ class WatchBridgeService { } } + Future _refreshAvailability() async { + try { + _applyAvailability(await _channel.invokeMethod('status')); + } on MissingPluginException { + // Expected on non-iOS test hosts and older generated projects. + } on PlatformException catch (error) { + debugError('[WATCH] Status failed: ${error.code}: ${error.message}'); + } + } + + void _applyAvailability( + Object? raw, { + bool refreshNativeState = false, + }) { + if (raw is! Map) return; + final available = raw['activated'] == true && + raw['paired'] == true && + raw['installed'] == true; + if (available == _canSync && !refreshNativeState) return; + _canSync = available; + + // Native forgets its application-context cache whenever WatchConnectivity + // reports a state change. Forget ours on the same notification even when + // availability remains true, or an installed replacement watch could wait + // forever for state whose fingerprint Dart still considers delivered. + if (!available || refreshNativeState) { + _lastPayload = null; + _lastUrgencyKey = null; + _lastSentAt = null; + _lastBuiltAt = null; + } + _availabilityHandler?.call(available); + } + void _rememberCommandId(String id) { _handledCommandIds.add(id); // Unbounded growth would leak across a long session. @@ -141,11 +189,13 @@ class WatchBridgeService { void schedule( WatchSnapshotBuilder snapshotBuilder, { + required WatchUrgencyKeyBuilder urgencyKeyBuilder, bool immediate = false, }) { - if (_disposed || !isSupportedPlatform) return; + if (_disposed || !canSync) return; _pendingSnapshotBuilder = snapshotBuilder; + _pendingUrgencyKeyBuilder = urgencyKeyBuilder; _scheduledUpdate?.cancel(); if (immediate) { @@ -168,8 +218,27 @@ class WatchBridgeService { _scheduledUpdate?.cancel(); _scheduledUpdate = null; - if (_disposed || !isSupportedPlatform) return; + if (_disposed || !canSync) return; + + // Urgency is intentionally a small scalar projection of provider state. + // If it has not changed, enforce the radio throttle before constructing, + // sorting, or JSON-encoding any geography. Phase/control/cue transitions + // still differ here and retain their existing immediate path. + final pendingUrgencyKey = _pendingUrgencyKeyBuilder?.call(); + final predictedUrgent = pendingUrgencyKey != _lastUrgencyKey; + final lastBuiltAt = _lastBuiltAt; + if (!predictedUrgent && lastBuiltAt != null) { + final elapsed = DateTime.now().difference(lastBuiltAt); + if (elapsed < _minimumNonUrgentInterval) { + _scheduledUpdate = Timer( + _minimumNonUrgentInterval - elapsed, + _enqueueFlush, + ); + return; + } + } + _lastBuiltAt = DateTime.now(); final snapshot = _pendingSnapshotBuilder?.call(); if (snapshot == null) { if (_lastPayload == null && _didReconcileNativeState) return; @@ -188,9 +257,9 @@ class WatchBridgeService { if (encoded == _lastPayload) return; final urgent = snapshot.urgencyKey != _lastUrgencyKey; - final lastSentAt = _lastSentAt; - if (!urgent && lastSentAt != null) { - final elapsed = DateTime.now().difference(lastSentAt); + final sentAt = _lastSentAt; + if (!urgent && sentAt != null) { + final elapsed = DateTime.now().difference(sentAt); if (elapsed < _minimumNonUrgentInterval) { _scheduledUpdate = Timer( _minimumNonUrgentInterval - elapsed, @@ -201,10 +270,18 @@ class WatchBridgeService { } try { - await _channel.invokeMethod('sync', { + final delivered = await _channel.invokeMethod('sync', { 'payload': payload, 'urgent': urgent, }); + if (delivered != true) { + // Native can lose availability between status and send. Do not cache + // a payload it refused. Re-query rather than guessing which condition + // failed, so a transient context error cannot permanently close the + // gate while the watch is actually still installed. + await _refreshAvailability(); + return; + } _didReconcileNativeState = true; _lastPayload = encoded; _lastUrgencyKey = snapshot.urgencyKey; @@ -232,6 +309,7 @@ class WatchBridgeService { _lastPayload = null; _lastUrgencyKey = null; _lastSentAt = null; + _lastBuiltAt = null; } } @@ -240,7 +318,9 @@ class WatchBridgeService { _scheduledUpdate?.cancel(); _scheduledUpdate = null; _pendingSnapshotBuilder = null; + _pendingUrgencyKeyBuilder = null; _commandHandler = null; _commandRefusalHandler = null; + _availabilityHandler = null; } } diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index aa68bda..4f12c7a 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -97,9 +97,8 @@ class WatchGeoBuilder { // The phone draws a multi-hop-only return as RX: it proves the packet // came back through the mesh, but not that any repeater heard us // directly. Keep the TX identity and mirror that evidence colour. - color: hasMultiHopOnly - ? pingColor('rx', true) - : pingColor('tx', success), + color: + hasMultiHopOnly ? pingColor('rx', true) : pingColor('tx', success), at: tx.timestamp, )); } @@ -157,6 +156,10 @@ class WatchGeoBuilder { int cap = WatchWire.maxRepeaters, }) { final located = repeaters.where((r) => r.hasLocation).toList(); + final heardRepeaters = resolveUniqueHexPrefixes( + repeaters: repeaters, + prefixes: heardThisCycle, + ).values.map((repeater) => repeater.id).toSet(); if (lat != null && lon != null) { // Distance is computed once per repeater rather than inside the @@ -179,17 +182,41 @@ class WatchGeoBuilder { return limited .map((r) => WatchRepeater( id: r.id, + hexId: r.hexId, name: r.name, lat: r.lat, lon: r.lon, color: repeaterColor(r), - heardThisCycle: - heardThisCycle.contains(r.id) || - heardThisCycle.contains(r.hexId), + heardThisCycle: heardRepeaters.contains(r.id), )) .toList(); } + /// Resolve path-hash prefixes only when the full repeater catalogue proves + /// the match unique. Applying this before the wrist's nearest-20 cap matters: + /// a second matching repeater outside that cap still makes a line or ring a + /// guess, and a confidently wrong relationship is worse than none. + static Map resolveUniqueHexPrefixes({ + required List repeaters, + required Iterable prefixes, + }) { + final normalized = prefixes + .map((prefix) => prefix.toUpperCase()) + .where((prefix) => prefix.isNotEmpty) + .toSet(); + final resolved = {}; + + for (final length in normalized.map((prefix) => prefix.length).toSet()) { + final index = indexByHexPrefix(repeaters, length); + for (final prefix + in normalized.where((prefix) => prefix.length == length)) { + final repeater = index[prefix]; + if (repeater != null) resolved[prefix] = repeater; + } + } + return resolved; + } + /// Dot colour for an overlay row, mirroring `_overlayTypeColor` on the map. static WatchColor overlayTypeColor(OverlayPingType type) => switch (type) { OverlayPingType.tx => WatchColor.fromColor(PingColors.txSuccess), @@ -212,31 +239,35 @@ class WatchGeoBuilder { required List<({String repeaterId, double snr, OverlayPingType type})> top, ({String repeaterId, double snr})? rxSlot, required Map repeaterByHex, - required DateTime at, + required DateTime? topAt, + required DateTime? rxAt, double? lat, double? lon, }) { final rows = []; - for (final entry in top) { + // Missing time means the provider cannot truthfully say when this set was + // heard. Omitting such a row is safer than presenting a plausible lie in + // Node Detail; every production mutation records its timestamp atomically. + for (final entry in top.where((_) => topAt != null)) { rows.add(_row( id: entry.repeaterId, snr: entry.snr, type: entry.type, repeaterByHex: repeaterByHex, - at: at, + at: topAt!, lat: lat, lon: lon, )); } - if (rxSlot != null) { + if (rxSlot != null && rxAt != null) { rows.add(_row( id: rxSlot.repeaterId, snr: rxSlot.snr, type: OverlayPingType.rx, repeaterByHex: repeaterByHex, - at: at, + at: rxAt, lat: lat, lon: lon, )); @@ -260,7 +291,10 @@ class WatchGeoBuilder { final repeater = repeaterByHex[hex]; double? distance; - if (lat != null && lon != null && repeater != null && repeater.hasLocation) { + if (lat != null && + lon != null && + repeater != null && + repeater.hasLocation) { distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 34c2a3d..6c1fee1 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -93,6 +93,7 @@ class WatchPing { class WatchRepeater { const WatchRepeater({ required this.id, + required this.hexId, required this.name, required this.lat, required this.lon, @@ -101,6 +102,7 @@ class WatchRepeater { }); final String id; + final String hexId; final String name; final double lat; final double lon; @@ -109,6 +111,7 @@ class WatchRepeater { Map toMap() => { 'id': id, + 'hexId': hexId, 'name': name, 'lat': lat, 'lon': lon, @@ -298,14 +301,37 @@ class WatchSnapshot { /// /// Deliberately excludes geo: a moving GPS would otherwise mark every /// update urgent and defeat the throttle entirely. - String get urgencyKey => [ - core.sessionId, - core.mode, - core.phase.wireValue, - core.phaseTitle, - core.phaseDetail ?? '', - core.phaseEndsAt?.millisecondsSinceEpoch ?? 0, - core.isConnected, + String get urgencyKey => buildUrgencyKey( + sessionId: core.sessionId, + mode: core.mode, + phase: core.phase, + phaseTitle: core.phaseTitle, + phaseDetail: core.phaseDetail, + phaseEndsAt: core.phaseEndsAt, + isConnected: core.isConnected, + controls: controls, + cue: cue, + ); + + static String buildUrgencyKey({ + required String sessionId, + required String mode, + required LiveActivityPhase phase, + required String phaseTitle, + required String? phaseDetail, + required DateTime? phaseEndsAt, + required bool isConnected, + required WatchControls controls, + required WatchHapticCue? cue, + }) => + [ + sessionId, + mode, + phase.wireValue, + phaseTitle, + phaseDetail ?? '', + phaseEndsAt?.millisecondsSinceEpoch ?? 0, + isConnected, controls.canStartStop, controls.canManualPing, controls.isSessionActive, diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index e5822cb..fb94aa9 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -2,7 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/models/log_entry.dart'; import 'package:mesh_mapper/models/ping_data.dart'; import 'package:mesh_mapper/models/repeater.dart'; -import 'package:mesh_mapper/providers/app_state_provider.dart' show OverlayPingType; +import 'package:mesh_mapper/providers/app_state_provider.dart' + show OverlayPingType; import 'package:mesh_mapper/services/watch/watch_geo_builder.dart'; import 'package:mesh_mapper/services/watch/watch_models.dart'; import 'package:mesh_mapper/utils/ping_colors.dart'; @@ -332,7 +333,7 @@ void main() { expect(built.map((r) => r.id), ['near', 'mid']); }); - test('marks heard-this-cycle by either short id or hex id', () { + test('marks an RX-only repeater heard through its unique hex prefix', () { final repeaters = [ _repeater(id: '01', hexId: 'AA11', lat: 47.6, lon: -122.3), _repeater(id: '02', hexId: 'BB22', lat: 47.6, lon: -122.3), @@ -341,14 +342,28 @@ void main() { final built = WatchGeoBuilder.buildRepeaters( repeaters: repeaters, - heardThisCycle: {'01', 'BB22'}, + // This is the RX slot's path hash; no Top Heard entry is needed for + // the repeater pin to receive its current-cycle ring. + heardThisCycle: {'BB'}, ); expect( {for (final r in built) r.id: r.heardThisCycle}, - {'01': true, '02': true, '03': false}, + {'01': false, '02': true, '03': false}, ); }); + + test('carries the full hex identity separately from the API id', () { + final built = WatchGeoBuilder.buildRepeaters( + repeaters: [ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3), + ], + heardThisCycle: const {}, + ); + + expect(built.single.id, '01'); + expect(built.single.hexId, '4E5D82'); + }); }); group('buildHeard — mirrors the map\'s Top Heard overlay', () { @@ -360,7 +375,8 @@ void main() { ], rxSlot: (repeaterId: 'B914', snr: 9.9), repeaterByHex: const {}, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12, 0, 1), ); // The RX slot trails even though its SNR is highest — it is a distinct @@ -376,7 +392,8 @@ void main() { ], rxSlot: (repeaterId: 'CC', snr: 1), repeaterByHex: const {}, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12), ); expect(built[0].typeColor, WatchColor.fromColor(PingColors.txSuccess)); @@ -394,7 +411,8 @@ void main() { ], rxSlot: (repeaterId: 'E', snr: 0), repeaterByHex: const {}, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: DateTime(2026, 8, 12), ); expect(built.length, WatchWire.maxHeard); @@ -405,9 +423,14 @@ void main() { top: const [(repeaterId: '4e5d', snr: 6, type: OverlayPingType.tx)], repeaterByHex: { '4E5D': _repeater( - id: '01', hexId: '4E5D82', name: 'Capitol Hill', lat: 47.61, lon: -122.3), + id: '01', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.61, + lon: -122.3), }, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: null, lat: 47.6, lon: -122.3, ); @@ -421,7 +444,8 @@ void main() { final built = WatchGeoBuilder.buildHeard( top: const [(repeaterId: 'AB', snr: 1, type: OverlayPingType.tx)], repeaterByHex: const {}, - at: DateTime(2026, 8, 12), + topAt: DateTime(2026, 8, 12), + rxAt: null, ); expect(built.single.id, 'AB'); @@ -432,6 +456,31 @@ void main() { }); group('indexByHexPrefix', () { + test('resolves a link from a path-hash prefix to the full hex', () { + final repeater = + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3); + + final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [repeater], + prefixes: const ['4E5D'], + ); + + expect(linked['4E5D'], same(repeater)); + }); + + test('an ambiguous link prefix resolves to nothing', () { + final linked = WatchGeoBuilder.resolveUniqueHexPrefixes( + repeaters: [ + _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3), + _repeater(id: '02', hexId: '4E99F1', lat: 47.7, lon: -122.3), + ], + prefixes: const ['4E'], + ); + + expect(linked, isEmpty, + reason: 'a line to the wrong repeater is worse than no line'); + }); + test('resolves a prefix owned by exactly one repeater', () { final index = WatchGeoBuilder.indexByHexPrefix([ _repeater(id: '01', hexId: '4E5D82', lat: 47.6, lon: -122.3, name: 'A'), diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index b3dc8a1..99c62dc 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -112,14 +114,73 @@ void main() { expect(map['phaseEndsAtMs'], 1760000000000.0); }); + test('heard time survives a rebuild without changing the fingerprint', () { + const heardAtMs = 1759999980000.0; + WatchSnapshot withBuildTime(int updatedAtMs) => WatchSnapshot( + core: _snapshot().core, + geo: WatchGeo( + pings: const [], + repeaters: const [], + heard: [ + WatchHeardNode( + id: '4E5D', + name: 'Capitol Hill', + snr: 8.5, + at: DateTime.fromMillisecondsSinceEpoch(1759999980000), + typeColor: const WatchColor(0, 1, 0), + ), + ], + linkedRepeaterIds: const [], + ), + controls: _snapshot().controls, + updatedAt: DateTime.fromMillisecondsSinceEpoch(updatedAtMs), + ); + + String fingerprint(WatchSnapshot snapshot) { + final payload = Map.from(snapshot.toMap()) + ..remove('updatedAtMs'); + return jsonEncode(payload); + } + + final first = withBuildTime(1759999999000); + final rebuilt = withBuildTime(1760000000000); + final heard = (first.toMap()['geo'] as Map)['heard'] as List; + expect((heard.first as Map)['atMs'], heardAtMs); + expect(fingerprint(rebuilt), fingerprint(first)); + }); + + test('repeater wire object carries both database and hex identities', () { + const repeater = WatchRepeater( + id: '01', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.6, + lon: -122.3, + color: WatchColor(1, 0, 0), + heardThisCycle: true, + ); + + expect(repeater.toMap().keys.toSet(), { + 'id', + 'hexId', + 'name', + 'lat', + 'lon', + 'color', + 'heardThisCycle', + }); + }); + test('phase duration rides along so the watch can draw its own bar', () { // Deadline plus duration is everything needed to compute the remaining // fraction locally, which is why the bar needs no per-second updates. - expect(_snapshot(phaseDurationMs: 45000).toMap()['phaseDurationMs'], 45000); + expect( + _snapshot(phaseDurationMs: 45000).toMap()['phaseDurationMs'], 45000); expect(_snapshot().toMap()['phaseDurationMs'], isNull); }); - test('wire version is stamped so the watch can refuse unknown payloads', () { + test('wire version is stamped so the watch can refuse unknown payloads', + () { expect(_snapshot().toMap()['wireVersion'], WatchWire.version); }); @@ -159,6 +220,9 @@ void main() { late WatchBridgeService bridge; late MethodChannel channel; late List handled; + late bool nativeAvailable; + late bool syncSucceeds; + late int syncCalls; setUp(() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -166,11 +230,31 @@ void main() { channel = const MethodChannel('meshmapper/watch_test'); bridge = WatchBridgeService(channel: channel); handled = []; + nativeAvailable = true; + syncSucceeds = true; + syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'activated': nativeAvailable, + 'paired': nativeAvailable, + 'installed': nativeAvailable, + }; + } + if (call.method == 'sync') { + syncCalls++; + return syncSucceeds; + } + return null; + }); }); tearDown(() { debugDefaultTargetPlatformOverride = null; bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); }); Future?> sendCommand(String id, String kind) async { @@ -187,6 +271,128 @@ void main() { return channel.codec.decodeEnvelope(result) as Map?; } + test('availability can open after attach without an app restart', () async { + nativeAvailable = false; + final changes = []; + bridge.attachCommandHandler( + (_) => null, + onAvailabilityChanged: changes.add, + ); + await Future.delayed(Duration.zero); + expect(bridge.canSync, isFalse); + var builds = 0; + bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + expect(builds, 0, + reason: 'an iPhone without a watch must not construct geo payloads'); + + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + {'activated': true, 'paired': true, 'installed': true}, + )), + null, + ); + expect(result, isNotNull); + expect(bridge.canSync, isTrue); + expect(changes, [true]); + }); + + test('a native false is not cached as a delivered snapshot', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + syncSucceeds = false; + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 2); + expect(syncCalls, 2, + reason: 'native refused both; neither payload was delivered'); + }); + + test('a watch state change resends even when availability stays true', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(syncCalls, 1); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + {'activated': true, 'paired': true, 'installed': true}, + )), + null, + ); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 2); + expect(syncCalls, 2, + reason: 'native cleared its context cache on the state change'); + }); + + test('the nonurgent throttle runs before the snapshot builder', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + var builds = 0; + + void schedule() => bridge.schedule( + () { + builds++; + return _snapshot(); + }, + urgencyKeyBuilder: () => _snapshot().urgencyKey, + immediate: true, + ); + + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + schedule(); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(builds, 1, + reason: 'geo construction waits until the throttle window opens'); + expect(syncCalls, 1); + }); + test('accepted commands reach the handler', () async { bridge.attachCommandHandler((kind) async { handled.add(kind); @@ -229,7 +435,8 @@ void main() { expect(handled, hasLength(2)); }); - test('an unknown command is refused without reaching the handler', () async { + test('an unknown command is refused without reaching the handler', + () async { bridge.attachCommandHandler((kind) async { handled.add(kind); return null; From 6e1112b48253823a4df926b6785bb8e7fa2a1c89 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 13:39:48 -0700 Subject: [PATCH 30/71] Tell the truth about starting, idling, and going stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five remaining review findings. **Stop was silently dropped while a session was starting.** Admission checked `_autoPingEnabled` but not `_autoPingStarting`, and during Start's awaited session check the first is false while the second is true — so a Stop arriving in that window was treated as "already stopped" and discarded, after which the session came up anyway. Start in that window is genuinely idempotent and is still accepted as a no-op; Stop is not, so it now refuses with "Still starting — try Stop again." No deferred queue: a refusal the wearer can act on beats hidden ordering they cannot see. **An idle watch claimed to be preparing a session.** The shared phase resolver maps "no session" onto Starting, which is right for the Live Activity — whose builder only runs during a session — and wrong for the watch, which is always present. A connected, GPS-locked, idle watch said "Preparing session…" indefinitely, including right after Stop. The watch projects that fallback to a new idle phase, "Ready / No session running"; the Live Activity still calls the shared resolver directly and is unchanged. **The outcome colour only ever read TX history**, so a Passive session showed whatever TX last did, and a multi-hop-only TX reported success while the map marker beside it drew RX purple — the same event described two ways. It now follows the newest event across all four histories and applies the map's multi-hop rule. **Staleness never invalidated the view.** `isStale` compared against `receivedAt` with nothing changing at the 90-second boundary, so on a durable phase a dead link could look current indefinitely. The boundary is an event now: one cancellable task per snapshot, not a poll — this is the app whose battery we spent the day cutting. **A failure cue replayed after a watch restart**, because the phone never cleared it and the watch deduped IDs in process memory only. The phone drops a cue once native accepts it, and the watch ignores anything undated or older than 30 seconds. Either half alone leaves the hole open. --- ios/MeshMapperWatch/WatchSessionClient.swift | 41 +++++++-- ios/Shared/MeshMapperWatchPayload.swift | 7 ++ lib/providers/app_state_provider.dart | 68 ++++++++++++--- .../live_activity/live_activity_models.dart | 8 +- lib/services/watch/watch_bridge_service.dart | 6 ++ lib/services/watch/watch_geo_builder.dart | 58 +++++++++++-- lib/services/watch/watch_models.dart | 59 ++++++++++++- .../watch/watch_geo_builder_test.dart | 60 +++++++++++++ .../watch/watch_wire_contract_test.dart | 87 ++++++++++++++++++- 9 files changed, 364 insertions(+), 30 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index f5e5473..7fac894 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -15,6 +15,11 @@ final class WatchSessionClient: NSObject { /// 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? @@ -48,11 +53,8 @@ final class WatchSessionClient: NSObject { /// 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 - - var isStale: Bool { - guard let receivedAt else { return true } - return Date().timeIntervalSince(receivedAt) > Self.staleAfter - } + private static let cueFreshFor: TimeInterval = 30 + private static let cueClockTolerance: TimeInterval = 5 /// Bring the session up and pull a current snapshot. /// @@ -62,7 +64,7 @@ final class WatchSessionClient: NSObject { #if DEBUG if SampleSnapshot.isEnabled { snapshot = SampleSnapshot.make() - receivedAt = Date() + markSnapshotReceived() return } #endif @@ -149,6 +151,29 @@ final class WatchSessionClient: NSObject { } } + private func markSnapshotReceived(at arrival: Date = Date()) { + staleBoundaryTask?.cancel() + receivedAt = arrival + 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(Self.staleAfter)) + guard !Task.isCancelled, self?.receivedAt == arrival 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) + // Phone and watch clocks normally agree, but a few seconds of skew must + // not suppress a real failure that has just crossed the radio. + return age >= -cueClockTolerance && age <= cueFreshFor + } + // MARK: - Ingest private func ingest(context: [String: Any]) { @@ -170,15 +195,17 @@ final class WatchSessionClient: NSObject { } Task { @MainActor in + let arrival = Date() self.versionMismatch = false self.snapshot = decoded - self.receivedAt = Date() + self.markSnapshotReceived(at: arrival) // 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. self.clearPendingCommand() if let cue = decoded.cue, + Self.isFresh(cue, at: arrival), self.presentedCueIDs.insert(cue.id).inserted { self.presentedCueIDOrder.append(cue.id) diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index f7dd927..c521a8d 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -142,9 +142,16 @@ 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 diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 0f72ee6..d144e37 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1228,7 +1228,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// 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 = _resolveLiveActivityPhase(); + final phase = _resolveWatchPhase(); final controls = _buildWatchControls(); return WatchSnapshot.buildUrgencyKey( sessionId: _liveActivitySessionId ?? 'idle', @@ -1251,7 +1251,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchSnapshot? _buildWatchSnapshot() { if (_isDisposed) return null; - final phase = _resolveLiveActivityPhase(); + final phase = _resolveWatchPhase(); final repeaterState = _buildLiveActivityRepeaters(); final now = DateTime.now(); final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); @@ -1500,12 +1500,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - /// Colour of the most recent completed ping, matching the map's markers. - WatchColor? _resolveWatchPingColor() { - if (_txPings.isEmpty) return null; - final latest = _txPings.last; - return WatchGeoBuilder.pingColor('tx', latest.heardRepeaters.isNotEmpty); - } + /// 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, + ); /// Decides whether an intent from the wrist may begin. /// @@ -1526,13 +1528,25 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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; if (!isConnected) return 'Not connected'; - if (_autoPingEnabled) return null; // Already running. unawaited(_runWatchStartSession(_resolvedWatchSessionMode)); return null; case WatchCommandKind.stopSession: - if (!_autoPingEnabled) return null; // Already stopped. + 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; @@ -1596,11 +1610,44 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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; + } + } + + ({ + 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) { @@ -2015,6 +2062,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _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. diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index 83d5e30..f8b7666 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -1,7 +1,12 @@ import '../watch/watch_color.dart'; -/// High-level phase shown by the iOS Live Activity. +/// 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, @@ -24,6 +29,7 @@ enum LiveActivityPhase { extension LiveActivityPhaseWireValue on LiveActivityPhase { String get wireValue => switch (this) { + LiveActivityPhase.idle => 'idle', LiveActivityPhase.active => 'active', LiveActivityPhase.starting => 'starting', LiveActivityPhase.sending => 'sending', diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 6d5ca20..67284e4 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -17,6 +17,7 @@ typedef WatchUrgencyKeyBuilder = String Function(); typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); typedef WatchCommandRefusalHandler = void Function(String reason); typedef WatchAvailabilityHandler = void Function(bool available); +typedef WatchSnapshotDeliveryHandler = void Function(WatchSnapshot snapshot); /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// @@ -43,6 +44,7 @@ class WatchBridgeService { WatchCommandHandler? _commandHandler; WatchCommandRefusalHandler? _commandRefusalHandler; WatchAvailabilityHandler? _availabilityHandler; + WatchSnapshotDeliveryHandler? _snapshotDeliveryHandler; String? _lastPayload; String? _lastUrgencyKey; @@ -65,10 +67,12 @@ class WatchBridgeService { WatchCommandHandler handler, { WatchCommandRefusalHandler? onRefusal, WatchAvailabilityHandler? onAvailabilityChanged, + WatchSnapshotDeliveryHandler? onSnapshotDelivered, }) { _commandHandler = handler; _commandRefusalHandler = onRefusal; _availabilityHandler = onAvailabilityChanged; + _snapshotDeliveryHandler = onSnapshotDelivered; if (!isSupportedPlatform) return; _channel.setMethodCallHandler(_handleNativeCall); unawaited(_refreshAvailability()); @@ -286,6 +290,7 @@ class WatchBridgeService { _lastPayload = encoded; _lastUrgencyKey = snapshot.urgencyKey; _lastSentAt = DateTime.now(); + _snapshotDeliveryHandler?.call(snapshot); } on MissingPluginException { // Expected on non-iOS hosts and in tests. } on PlatformException catch (error) { @@ -322,5 +327,6 @@ class WatchBridgeService { _commandHandler = null; _commandRefusalHandler = null; _availabilityHandler = null; + _snapshotDeliveryHandler = null; } } diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 4f12c7a..4c80151 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -60,6 +60,54 @@ class WatchGeoBuilder { } } + static WatchColor _txPingColor(TxPing ping) { + final success = ping.heardRepeaters.isNotEmpty; + final hasDirectEcho = + ping.heardRepeaters.any((repeater) => repeater.pathHops == null); + final hasMultiHopOnly = !hasDirectEcho && success; + // A multi-hop-only return is RX evidence, not proof that a repeater heard + // the transmitter directly. This is the phone map's marker rule. + return hasMultiHopOnly ? pingColor('rx', true) : pingColor('tx', success); + } + + /// Outcome colour for the newest coverage event of any kind. + /// + /// Scanning the four bounded histories avoids constructing and sorting the + /// map-marker list a second time merely to colour one dot. + static WatchColor? latestPingColor({ + required List txPings, + required List rxPings, + required List discLogEntries, + required List traceLogEntries, + }) { + DateTime? latestAt; + WatchColor? latestColor; + + void consider(DateTime at, WatchColor color) { + if (latestAt == null || at.isAfter(latestAt!)) { + latestAt = at; + latestColor = color; + } + } + + for (final ping in txPings) { + consider(ping.timestamp, _txPingColor(ping)); + } + for (final ping in rxPings) { + consider(ping.timestamp, pingColor('rx', true)); + } + for (final entry in discLogEntries) { + consider( + entry.timestamp, + pingColor('disc', entry.discoveredNodes.isNotEmpty), + ); + } + for (final entry in traceLogEntries) { + consider(entry.timestamp, pingColor('trace', entry.success)); + } + return latestColor; + } + /// Colour for a repeater pin, matching the iOS map's `_repeaterStatusColor`. static WatchColor repeaterColor(Repeater repeater) { if (repeater.isDead) return WatchColor.fromColor(PingColors.repeaterDead); @@ -85,20 +133,12 @@ class WatchGeoBuilder { for (var i = 0; i < txPings.length; i++) { final tx = txPings[i]; - final success = tx.heardRepeaters.isNotEmpty; - final hasDirectEcho = - tx.heardRepeaters.any((repeater) => repeater.pathHops == null); - final hasMultiHopOnly = !hasDirectEcho && success; pings.add(WatchPing( id: 'tx-${tx.timestamp.millisecondsSinceEpoch}-$i', lat: tx.latitude, lon: tx.longitude, kind: 'tx', - // The phone draws a multi-hop-only return as RX: it proves the packet - // came back through the mesh, but not that any repeater heard us - // directly. Keep the TX identity and mirror that evidence colour. - color: - hasMultiHopOnly ? pingColor('rx', true) : pingColor('tx', success), + color: _txPingColor(tx), at: tx.timestamp, )); } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 6c1fee1..2d4a9f3 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -228,13 +228,22 @@ class WatchControls { /// Carries an [id] so the watch fires exactly once: diffing state would /// double-fire on redelivery, which WatchConnectivity does routinely. class WatchHapticCue { - const WatchHapticCue({required this.id, required this.kind, this.message}); + const WatchHapticCue({ + required this.id, + required this.kind, + required this.issuedAt, + this.message, + }); final String id; /// 'success' | 'failure' | 'notification' final String kind; + /// Creation time lets a restarted watch distinguish a current failure from + /// an old cue retained in WatchConnectivity's application context. + final DateTime issuedAt; + /// Human-readable detail for an event whose outcome arrived after command /// admission. This additive field is optional, so v2 remains decodable; no /// wire bump is needed while the matched phone and watch targets ship it. @@ -243,6 +252,7 @@ class WatchHapticCue { Map toMap() => { 'id': id, 'kind': kind, + 'issuedAtMs': issuedAt.millisecondsSinceEpoch.toDouble(), 'message': message, }; } @@ -353,3 +363,50 @@ enum WatchCommandKind { return null; } } + +typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); + +/// Resolve the wrist's single Start/Stop control without racing the phone's +/// asynchronous start transaction. A second Start is the same intent and can +/// disappear harmlessly; Stop is the opposite intent, so claiming success +/// before there is a running session would lie to the wearer. +WatchCommandAdmission resolveWatchSessionCommandAdmission({ + required WatchCommandKind kind, + required bool isSessionActive, + required bool isSessionStarting, +}) { + switch (kind) { + case WatchCommandKind.startSession: + return ( + shouldRun: !isSessionActive && !isSessionStarting, + refusal: null, + ); + case WatchCommandKind.stopSession: + if (isSessionStarting && !isSessionActive) { + return ( + shouldRun: false, + refusal: 'Still starting — try Stop again', + ); + } + return (shouldRun: isSessionActive, refusal: null); + case WatchCommandKind.manualPing: + case WatchCommandKind.requestSnapshot: + throw ArgumentError.value(kind, 'kind', 'Expected Start or Stop'); + } +} + +/// The shared resolver's Starting fallback is correct for a Live Activity, +/// which only exists for a session, but the watch also renders while idle. +/// Only the watch calls this projection, keeping the phone surface unchanged. +LiveActivityPhase resolveWatchSurfacePhase({ + required LiveActivityPhase sharedPhase, + required bool isSessionActive, + required bool isSessionStarting, +}) { + if (sharedPhase == LiveActivityPhase.starting && + !isSessionActive && + !isSessionStarting) { + return LiveActivityPhase.idle; + } + return sharedPhase; +} diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index fb94aa9..b2c8a3c 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -171,6 +171,66 @@ void main() { ); }); + test('outcome colour follows the newest event across every history', () { + final base = DateTime(2026, 8, 12, 10); + final tx = _tx( + base, + heard: const [HeardRepeater(repeaterId: '4e', snr: 6)], + ); + final discovery = _disc( + base.add(const Duration(seconds: 1)), + discovered: true, + ); + final trace = _trace( + base.add(const Duration(seconds: 2)), + success: false, + ); + final rx = _rx(base.add(const Duration(seconds: 3))); + + WatchColor? latest({ + List rxPings = const [], + List traces = const [], + }) => + WatchGeoBuilder.latestPingColor( + txPings: [tx], + rxPings: rxPings, + discLogEntries: [discovery], + traceLogEntries: traces, + ); + + expect(latest(), WatchColor.fromColor(PingColors.discSuccess)); + expect( + latest(traces: [trace]), + WatchColor.fromColor(PingColors.noResponse), + ); + expect( + latest(rxPings: [rx], traces: [trace]), + WatchColor.fromColor(PingColors.rx), + ); + }); + + test('latest outcome uses the marker rule for multi-hop-only TX', () { + final multiHop = _tx( + DateTime(2026, 8, 12, 10), + heard: const [ + HeardRepeater( + repeaterId: '4e', + snr: 6, + pathHops: ['7a', '4e'], + ), + ], + ); + + final color = WatchGeoBuilder.latestPingColor( + txPings: [multiHop], + rxPings: const [], + discLogEntries: const [], + traceLogEntries: const [], + ); + + expect(color, WatchColor.fromColor(PingColors.rx)); + }); + test('discovery markers use response success and failure colours', () { final answered = _disc( DateTime(2026, 8, 12, 10, 1), diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 99c62dc..e30a32b 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -171,6 +171,23 @@ void main() { }); }); + test('failure cues carry their creation time for restart filtering', () { + final issuedAt = DateTime.utc(2026, 8, 12, 10); + final cue = WatchHapticCue( + id: 'failure-1', + kind: 'failure', + issuedAt: issuedAt, + message: 'Could not start', + ); + + expect(cue.toMap(), { + 'id': 'failure-1', + 'kind': 'failure', + 'issuedAtMs': issuedAt.millisecondsSinceEpoch.toDouble(), + 'message': 'Could not start', + }); + }); + test('phase duration rides along so the watch can draw its own bar', () { // Deadline plus duration is everything needed to compute the remaining // fraction locally, which is why the bar needs no per-second updates. @@ -197,8 +214,13 @@ void main() { isNot(base.urgencyKey), ); expect( - _snapshot(cue: const WatchHapticCue(id: 'c1', kind: 'success')) - .urgencyKey, + _snapshot( + cue: WatchHapticCue( + id: 'c1', + kind: 'success', + issuedAt: DateTime.utc(2026, 8, 12), + ), + ).urgencyKey, isNot(base.urgencyKey), ); }); @@ -214,6 +236,46 @@ void main() { test('an unknown command is rejected rather than guessed at', () { expect(WatchCommandKind.fromWire('selfDestruct'), isNull); }); + + test('Start is idempotent while starting but Stop tells the truth', () { + final start = resolveWatchSessionCommandAdmission( + kind: WatchCommandKind.startSession, + isSessionActive: false, + isSessionStarting: true, + ); + final stop = resolveWatchSessionCommandAdmission( + kind: WatchCommandKind.stopSession, + isSessionActive: false, + isSessionStarting: true, + ); + + expect(start, (shouldRun: false, refusal: null)); + expect(stop.shouldRun, isFalse); + expect(stop.refusal, 'Still starting — try Stop again'); + }); + + test('only the always-present watch projects shared Starting to idle', () { + const sharedLiveActivityPhase = LiveActivityPhase.starting; + + expect( + resolveWatchSurfacePhase( + sharedPhase: sharedLiveActivityPhase, + isSessionActive: false, + isSessionStarting: false, + ), + LiveActivityPhase.idle, + ); + expect(sharedLiveActivityPhase, LiveActivityPhase.starting, + reason: 'the Live Activity consumes the shared resolver directly'); + expect( + resolveWatchSurfacePhase( + sharedPhase: sharedLiveActivityPhase, + isSessionActive: false, + isSessionStarting: true, + ), + LiveActivityPhase.starting, + ); + }); }); group('bridge command handling', () { @@ -393,6 +455,27 @@ void main() { expect(syncCalls, 1); }); + test('successful native delivery reports the exact snapshot once', + () async { + WatchSnapshot? delivered; + bridge.attachCommandHandler( + (_) => null, + onSnapshotDelivered: (snapshot) => delivered = snapshot, + ); + await Future.delayed(Duration.zero); + final snapshot = _snapshot(); + + bridge.schedule( + () => snapshot, + urgencyKeyBuilder: () => snapshot.urgencyKey, + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(delivered, same(snapshot)); + expect(syncCalls, 1); + }); + test('accepted commands reach the handler', () async { bridge.attachCommandHandler((kind) async { handled.add(kind); From 10f2e1291c21665764379d78492e8037bec7f4a6 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 13:40:01 -0700 Subject: [PATCH 31/71] Retry Live Activities within a session after they become available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inherited from the `liveactivities` fork, not from the watch work, and kept separate so it can be reverted on its own. `_unavailableSessionId` suppressed every further attempt for a session ID once native reported Live Activities unavailable. Turning them on in Settings mid-wardrive therefore did nothing until a new session started — and a wardrive is exactly the situation where you cannot conveniently start a new session to pick up a setting you just changed. Availability is re-checked every 30 seconds within the same session, so the activity appears once it is permitted, without retry churn while it is not. --- .../live_activity/live_activity_service.dart | 35 ++++++++++-- .../live_activity_service_test.dart | 54 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 test/services/live_activity/live_activity_service_test.dart diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart index bf5cb27..e1e85f3 100644 --- a/lib/services/live_activity/live_activity_service.dart +++ b/lib/services/live_activity/live_activity_service.dart @@ -14,17 +14,26 @@ typedef LiveActivitySnapshotBuilder = LiveActivitySnapshot? Function(); /// Timer ticks are represented by absolute phase deadlines, allowing SwiftUI to /// render the countdown locally without an ActivityKit update every second. class LiveActivityService { - static const MethodChannel _channel = - MethodChannel('meshmapper/live_activity'); + LiveActivityService({ + @visibleForTesting MethodChannel? channel, + @visibleForTesting + Duration unavailableRetryDelay = const Duration(seconds: 30), + }) : _channel = channel ?? const MethodChannel('meshmapper/live_activity'), + _unavailableRetryDelay = unavailableRetryDelay; + static const Duration _debounceDelay = Duration(milliseconds: 200); static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + final MethodChannel _channel; + final Duration _unavailableRetryDelay; + Timer? _scheduledUpdate; LiveActivitySnapshotBuilder? _pendingSnapshotBuilder; String? _lastPayload; String? _lastUrgencyKey; DateTime? _lastSentAt; String? _unavailableSessionId; + DateTime? _unavailableRetryAt; bool _disposed = false; bool _didReconcileNativeState = false; Future _operationChain = Future.value(); @@ -70,9 +79,21 @@ class LiveActivityService { return; } - if (_unavailableSessionId == snapshot.sessionId) return; - if (_unavailableSessionId != null) { + 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(); @@ -103,9 +124,13 @@ class LiveActivityService { _didReconcileNativeState = true; if (result == false) { _unavailableSessionId = snapshot.sessionId; + _unavailableRetryAt = DateTime.now().add(_unavailableRetryDelay); + _scheduledUpdate = Timer(_unavailableRetryDelay, _enqueueFlush); debugLog('[LIVE ACTIVITY] Live Activities are unavailable or disabled'); return; } + _unavailableSessionId = null; + _unavailableRetryAt = null; _lastPayload = encoded; _lastUrgencyKey = snapshot.urgencyKey; _lastSentAt = DateTime.now(); @@ -142,6 +167,7 @@ class LiveActivityService { _lastUrgencyKey = null; _lastSentAt = null; _unavailableSessionId = null; + _unavailableRetryAt = null; } } @@ -150,5 +176,6 @@ class LiveActivityService { _scheduledUpdate?.cancel(); _scheduledUpdate = null; _pendingSnapshotBuilder = null; + _unavailableRetryAt = null; } } diff --git a/test/services/live_activity/live_activity_service_test.dart b/test/services/live_activity/live_activity_service_test.dart new file mode 100644 index 0000000..cca49bb --- /dev/null +++ b/test/services/live_activity/live_activity_service_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; +import 'package:mesh_mapper/services/live_activity/live_activity_service.dart'; + +LiveActivitySnapshot _snapshot() => LiveActivitySnapshot( + sessionId: 'session-1', + mode: 'Passive', + phase: LiveActivityPhase.listeningDiscovery, + phaseTitle: 'Listening…', + isConnected: true, + txCount: 0, + rxCount: 2, + discoveryCount: 1, + traceCount: 0, + queueSize: 0, + repeaters: const [], + totalHeardCount: 0, + repeatersAreCurrent: true, + updatedAt: DateTime.utc(2026, 8, 13), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('unavailable Live Activities retry within the same session', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_retry_test'); + var syncCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'sync') return null; + syncCalls++; + return syncCalls > 1; + }); + final service = LiveActivityService( + channel: channel, + unavailableRetryDelay: const Duration(milliseconds: 20), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + service.schedule(_snapshot, immediate: true); + await Future.delayed(const Duration(milliseconds: 80)); + + expect(syncCalls, 2, + reason: 'enabling the feature must not require a new session ID'); + }); +} From aef4030bf625217d174240bcbe76e80fccbc3671 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 14:00:04 -0700 Subject: [PATCH 32/71] Offer the readout as a main page, not only when the wrist drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "It makes me wish that was a permanent option alongside the map." Main page is now a choice: Map, or the readout Always-On already used. The readout's layout is untouched — it was approved on hardware, so this makes it reachable deliberately rather than redesigning it. Mode and luminance are separate axes, and only luminance existed before. The mode decides which surface is shown; the refresh rate decides how precisely it can update. So the readout chosen at full brightness shows a precise countdown, while the same readout dimmed keeps "<1 min" — the coarse value exists because Always-On refreshes about once a minute and a seconds figure would be silently wrong, which is not true when the wearer picked this view on a lit screen. That is Adam's own rule about the phone's always-on bar, applied in the other direction. Choosing the readout costs nothing it does not use: no MapReader, no MapKit, no annotations, no camera callbacks, no recentring, no panel measurement. A readout with the map still alive behind a black view would have been pointless, since being the cheapest surface in the app is the entire reason to offer it. The heard list now gets its own page whenever the readout is selected, regardless of the sheet preference. The sheet opens by tapping the map's status panel, which the readout does not have — so honouring "sheet" there would have left the list with no way in at all. A choice of main page must not make a feature unreachable. `MeshMapperForceDimmed` still forces reduced luminance specifically rather than the readout, so Always-On remains reviewable in both modes. --- ios/MeshMapperWatch/ContentView.swift | 6 +- ios/MeshMapperWatch/MapPage.swift | 276 ++++++++++++++++-------- ios/MeshMapperWatch/SettingsPage.swift | 5 + ios/MeshMapperWatch/WatchSettings.swift | 25 +++ 4 files changed, 219 insertions(+), 93 deletions(-) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index 38ff172..265741d 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -31,7 +31,11 @@ struct ContentView: View { MapPage().tag(0) ControlsPage().tag(1) - if settings.nodeListPlacement == .page { + // 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 { NavigationStack { NodeListView() .navigationTitle("Heard") diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index e37fad2..63a4adf 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -24,6 +24,13 @@ struct MapPage: View { return environmentLuminanceReduced } + /// Content choice and display cadence are independent. A chosen readout can + /// run at full luminance with a precise timer; reduced luminance selects the + /// same approved surface because MapKit is not worth its Always-On cost. + private var showsMap: Bool { + settings.mainPageContent == .map && !isLuminanceReduced + } + @State private var camera: MapCameraPosition = .automatic /// Centre we last drove the camera to, so a camera change can be attributed @@ -129,29 +136,16 @@ struct MapPage: View { } var body: some View { - // The proxy is the only reliable way to relate a coordinate to a point on - // screen. The map draws outside its own layout frame — it ignores the - // bottom safe area, and on a 46 mm watch the frame SwiftUI reports is - // 159 pt tall against a 248 pt display — so no measurement of the view - // hierarchy predicts where a coordinate will actually land. Asking the map - // sidesteps the whole question. - MapReader { proxy in - content(proxy) - } - } - - private func content(_ proxy: MapProxy) -> some View { - ZStack { - if isLuminanceReduced { - // Always-On spends most of a long session with the wrist down. A live - // MapKit renderer and its annotations buy no useful glance information - // at reduced luminance, so remove that subtree rather than merely - // covering it. - Color.black.ignoresSafeArea() - dimmedStatus + Group { + if showsMap { + // The proxy is the only reliable way to relate a coordinate to a point + // on screen. Keep the reader inside this branch: constructing even map + // infrastructure behind the readout would defeat its battery purpose. + MapReader { proxy in + mapContent(proxy) + } } else { - map(proxy) - mapOverlay(proxy) + readoutContent } } .background( @@ -164,15 +158,10 @@ struct MapPage: View { } // Plain is intentional: `.ignoresSafeArea()` makes this reader report // the insets of its own expanded region, which are zero. The first - // nonzero value is latched while the panel is still in its safe fallback - // placement; later panel geometry depends on it, while the display's - // actual safe area is a device constant that cannot legitimately change. + // nonzero value is latched before any dependent panel geometry can feed + // back into layout. The display's actual safe area is a device constant + // that cannot legitimately change when the selected content does. ) - .onPreferenceChange(PanelFrameKey.self) { frame in - guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } - panelFrame = frame - recenterIfFollowing(proxy) - } .sheet(isPresented: $showingNodes) { NavigationStack { NodeListView() @@ -180,6 +169,27 @@ struct MapPage: View { .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 + } + #endif + } + } + + private func mapContent(_ proxy: MapProxy) -> some View { + ZStack { + map(proxy) + mapOverlay(proxy) + } + .onPreferenceChange(PanelFrameKey.self) { frame in + guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } + panelFrame = frame + recenterIfFollowing(proxy) + } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in recenterIfFollowing(proxy) } @@ -188,26 +198,30 @@ struct MapPage: View { .onChange(of: followSuspendedUntil) { _, until in if until == nil { recenterIfFollowing(proxy) } } - .onChange(of: isLuminanceReduced) { _, reduced in - // Camera work is forbidden while dimmed. Returning to full luminance is - // itself a state change, so following can resume immediately even if the - // phone has not produced another GPS fix yet. - if !reduced { recenterIfFollowing(proxy) } - } .onAppear { recenterIfFollowing(proxy) - #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 - } - #endif + } + .onDisappear { + // A pan's delayed resume belongs to the map. Leaving for either the + // chosen readout or Always-On must not leave map work pending off-screen. + resumeTask?.cancel() + resumeTask = nil + } + } + + 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 } } /// Map chrome remains an overlay so its measured frame can place the fix in - /// the visible band above it. Always-On has its own hierarchy and therefore + /// the visible band above it. The readout has its own hierarchy and therefore /// cannot accidentally inherit this bottom-pinned card again. private func mapOverlay(_ proxy: MapProxy) -> some View { VStack(spacing: 0) { @@ -232,16 +246,16 @@ struct MapPage: View { .ignoresSafeArea(edges: curvedPanelHorizontalInset == nil ? [] : .bottom) } - /// A full-screen glance surface for Always-On, not a map card without a map. + /// 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 dimmedStatus: some View { + private var readoutStatus: some View { VStack(alignment: .leading, spacing: 0) { if let snapshot { - dimmedPhase(snapshot) + ReadoutPhase(snapshot: snapshot, isLuminanceReduced: isLuminanceReduced) } else { Text("Waiting for iPhone") .font(.system(size: 17, weight: .bold)) @@ -263,7 +277,7 @@ struct MapPage: View { .frame(maxWidth: .infinity, alignment: .leading) } else { ForEach(Array(heard.prefix(4))) { node in - dimmedHeardRow(node) + readoutHeardRow(node) } } } @@ -274,54 +288,15 @@ struct MapPage: View { .dynamicTypeSize(.small ... .large) .opacity(client.isStale ? 0.5 : 1.0) // Snapshot replacement may arrive with an animated transaction from an - // ancestor. Always-On changes in discrete steps; it never interpolates. + // ancestor. Readout state changes are discrete; only the native precise + // countdown updates between them at full luminance. .transaction { $0.animation = nil } } - /// The two readings an Always-On glance exists to answer, each with its own - /// line instead of competing inside the map overlay's narrow bar. - /// - /// A seconds figure can be nearly a minute wrong while watchOS throttles an - /// Always-On screen. Showing whole minutes (or “<1 min”) makes that cadence - /// honest, while the isolated minute schedule avoids waking the rest of the - /// hierarchy. A static progress fill would repeat that number less precisely - /// and spend both pixels and compositing work, so the dimmed surface omits it. - private func dimmedPhase(_ snapshot: WatchSnapshot) -> some View { - TimelineView(.periodic(from: .now, by: 60)) { context in - let lapsed = snapshot.phaseEndsAt.map { $0 <= context.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 let countdown = dimmedCountdown(snapshot, at: context.date) { - Text(countdown) - .font(.system(size: 24, weight: .bold).monospacedDigit()) - .foregroundStyle(.white) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - } - } - - private func dimmedCountdown(_ snapshot: WatchSnapshot, at date: Date) -> String? { - guard let endsAt = snapshot.phaseEndsAt else { return nil } - let remaining = endsAt.timeIntervalSince(date) - guard remaining > 0 else { return nil } - if remaining < 60 { return "<1 min" } - return "\(Int(ceil(remaining / 60))) min" - } - /// 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 dimmedHeardRow(_ node: WatchHeardNode) -> some View { + private func readoutHeardRow(_ node: WatchHeardNode) -> some View { HStack(spacing: 5) { Circle() .fill(Color(node.typeColor)) @@ -613,7 +588,7 @@ struct MapPage: View { // MARK: - Camera private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { - guard !isLuminanceReduced, force || isFollowing, let fix else { return } + guard showsMap, force || isFollowing, let fix else { return } let center = centerPlacing(fix, proxy: proxy) programmaticCenter = center let region = MKCoordinateRegion(center: center, span: currentSpan) @@ -670,7 +645,7 @@ struct MapPage: View { /// The deadband is what stops it: each pass lands within a couple of points, /// the next sees no error worth fixing, and it settles. private func correctPlacement(_ proxy: MapProxy) { - guard !isLuminanceReduced, isFollowing, let fix, let targetPoint, + guard showsMap, isFollowing, let fix, let targetPoint, let point = proxy.convert(fix, to: .global) else { return } guard abs(point.y - targetPoint.y) > 6 else { return } @@ -901,6 +876,123 @@ extension Comparable { } } +/// 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 + ) + } + + var body: some View { + Group { + if isLuminanceReduced { + // A seconds figure can be nearly a minute wrong while watchOS throttles + // Always-On. Match that cadence explicitly instead of showing false + // precision, and isolate the minute wake-up to this small subtree. + TimelineView(.periodic(from: .now, by: 60)) { context in + phase(at: context.date, usesCoarseCountdown: true) + } + } else { + phase(at: Date(), usesCoarseCountdown: false) + } + } + .task(id: phaseKey) { + await trackDeadline() + } + } + + @ViewBuilder + private func phase(at date: Date, usesCoarseCountdown: Bool) -> some View { + let lapsed = usesCoarseCountdown + ? snapshot.phaseEndsAt.map { $0 <= date } ?? false + : deadlineLapsed + + 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 usesCoarseCountdown { + if let countdown = coarseCountdown(at: date) { + countdownText(countdown) + } + } else if !lapsed, let endsAt = snapshot.phaseEndsAt, endsAt > date { + 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) + } + } + } + + private func countdownText(_ value: String) -> some View { + Text(value) + .font(.system(size: 24, weight: .bold).monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func coarseCountdown(at date: Date) -> String? { + guard let endsAt = snapshot.phaseEndsAt else { return nil } + let remaining = endsAt.timeIntervalSince(date) + guard remaining > 0 else { return nil } + if remaining < 60 { return "<1 min" } + return "\(Int(ceil(remaining / 60))) min" + } + + @MainActor + private func trackDeadline() async { + let now = Date() + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false + } + + // The minute timeline owns this boundary under Always-On. Sleeping for an + // exact second there would imply precision the display cannot present. + 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 { diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 99b0d23..12becf0 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -18,6 +18,11 @@ struct SettingsPage: View { } Section("Layout") { + 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) diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 9a4f52b..e09b465 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -13,6 +13,7 @@ final class WatchSettings { static let showLinks = "map.showLinks" static let follow = "map.follow" static let mapLatitudeDelta = "map.latitudeDelta" + static let mainPageContent = "layout.mainPageContent" static let nodeListPlacement = "layout.nodeListPlacement" } @@ -40,6 +41,24 @@ final class WatchSettings { } } + /// 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" + } + } + } + private let defaults: UserDefaults init(defaults: UserDefaults = .standard) { @@ -56,6 +75,8 @@ final class WatchSettings { 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 } @@ -92,6 +113,10 @@ final class WatchSettings { } } + var mainPageContent: MainPageContent { + didSet { defaults.set(mainPageContent.rawValue, forKey: Key.mainPageContent) } + } + var nodeListPlacement: NodeListPlacement { didSet { defaults.set(nodeListPlacement.rawValue, forKey: Key.nodeListPlacement) } } From 001812ac81e6ef22381ace3ce7ea9968a6f7d018 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 14:09:33 -0700 Subject: [PATCH 33/71] Say the smallest true thing the dimmed countdown can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "<1 min" was the only sub-minute value, which is accurate and nearly contentless: it reads the same at fifty seconds as at five. The ladder is now "<15 sec", "<30 sec", "<1 min", with the existing minute display above. This adds precision without costing honesty, which is worth stating because it looks like the opposite trade. These are upper bounds, and an upper bound never becomes false as time passes — remaining time only decreases, so a "<15 sec" rendered just before Always-On stops refreshing is still true a minute later. The old value was equally true then, just less useful. The bound is resolved from the live deadline on every render rather than carried forward, because a new and longer phase starting under a stale display is the one case where a retained bound could be wrong. Verified on a 40 mm: "<30 sec" renders alongside the phase title with room to spare, and the tighter rung is a shorter string. Only the reduced-luminance presentation changes — the readout chosen at full brightness keeps its precise countdown, and the map panel is untouched. Worth knowing when testing this: the dimmed readout re-renders once a minute by design, so the rung only tightens when Always-On actually refreshes. Watching a running countdown will not show it step down. --- ios/MeshMapperWatch/MapPage.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 63a4adf..c93189b 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -960,6 +960,13 @@ private struct ReadoutPhase: View { guard let endsAt = snapshot.phaseEndsAt else { return nil } let remaining = endsAt.timeIntervalSince(date) guard remaining > 0 else { return nil } + // These are upper bounds, not estimates. Remaining time only decreases, + // so a tight statement rendered just before Always-On stops refreshing + // stays true afterward. Resolve it from this phase's live deadline on each + // render; retaining a previous phase's bound could make that guarantee + // false when a new, longer countdown begins. + if remaining < 15 { return "<15 sec" } + if remaining < 30 { return "<30 sec" } if remaining < 60 { return "<1 min" } return "\(Int(ceil(remaining / 60))) min" } From 20bc491c590ef35ce3adf9671f731fc0cf36f32d Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 14:58:14 -0700 Subject: [PATCH 34/71] Put the map/readout switch in the system's corner, not ours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A top-bar button, so the wearer can leave the map for the low-power readout without going to Settings. The glyph names the destination and the tap writes `mainPageContent` directly, so there is one source of truth and the choice survives a relaunch. It is absent at reduced luminance, where the readout is forced anyway and a control would be inert chrome on the one surface whose value is drawing almost nothing. Placing it by hand in the top-left was the obvious route and the wrong one: that corner is curved glass, and every hand-placed element there has cost us clearance math and clipped on hardware twice. The system draws a `.topBarLeading` item in that corner itself. Doing so needs a navigation host, and watchOS is particular about where it lives. A `.verticalPage` TabView already hosts each page in a navigation controller, so a `NavigationStack` inside a page nests wrapped controllers and aborts in `PUICStackedNavigationBar layoutSubviews` — a launch crash, not a layout glitch. It reproduced on both watch sizes whenever the readout was the selected surface. The map appeared to survive only because it draws over the top safe area, so the bar never took a visible layout pass; giving the toolbar a stable host made both surfaces crash and exposed that. Without any stack the toolbar is legal but renders nothing. One stack hoisted around the whole TabView satisfies both rules, so the Heard page loses its own — it keeps its title, which now resolves against the hoisted host. Panel geometry measured pixel-identical to HEAD on 40 mm and 46 mm: same edges, same bottom gap, same size. The latched safe-area inset and every clearance derived from it are untouched. --- ios/MeshMapperWatch/ContentView.swift | 33 ++++++---- ios/MeshMapperWatch/MapPage.swift | 95 ++++++++++++++++++--------- 2 files changed, 83 insertions(+), 45 deletions(-) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index 265741d..d5a01bc 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -27,26 +27,31 @@ struct ContentView: View { } var body: some View { - TabView(selection: $selection) { - MapPage().tag(0) - ControlsPage().tag(1) + // 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().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 { - NavigationStack { + // 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) } - .tag(2) - } - DebugPage().tag(3) - SettingsPage().tag(4) + DebugPage().tag(3) + SettingsPage().tag(4) + } + .tabViewStyle(.verticalPage) } - .tabViewStyle(.verticalPage) } } diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index c93189b..3ec0e4e 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -136,7 +136,48 @@ struct MapPage: View { } var body: some View { - Group { + pageContent + .toolbar { + if !isLuminanceReduced { + ToolbarItem(placement: .topBarLeading) { + mainPageToggle + } + } + } + .background( + GeometryReader { geo in + Color.clear + .onAppear { latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) } + .onChange(of: geo.safeAreaInsets.bottom) { _, inset in + latchBottomSafeAreaInset(inset) + } + } + // Plain is intentional: `.ignoresSafeArea()` makes this reader report + // the insets of its own expanded region, which are zero. The first + // nonzero value is latched before any dependent panel geometry can feed + // back into layout. The display's actual safe area is a device constant + // that cannot legitimately change when the selected content does. + ) + .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 + } + #endif + } + } + + private var pageContent: some View { + ZStack { if showsMap { // The proxy is the only reliable way to relate a coordinate to a point // on screen. Keep the reader inside this branch: constructing even map @@ -148,36 +189,24 @@ struct MapPage: View { readoutContent } } - .background( - GeometryReader { geo in - Color.clear - .onAppear { latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) } - .onChange(of: geo.safeAreaInsets.bottom) { _, inset in - latchBottomSafeAreaInset(inset) - } - } - // Plain is intentional: `.ignoresSafeArea()` makes this reader report - // the insets of its own expanded region, which are zero. The first - // nonzero value is latched before any dependent panel geometry can feed - // back into layout. The display's actual safe area is a device constant - // that cannot legitimately change when the selected content does. - ) - .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 - } - #endif + } + + 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") } private func mapContent(_ proxy: MapProxy) -> some View { @@ -502,7 +531,11 @@ struct MapPage: View { noteCameraChange(context.region.center) correctPlacement(proxy) } - .ignoresSafeArea(edges: .bottom) + // 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 + // transient trailing recentre button away from the leading toolbar control. + .ignoresSafeArea(edges: [.top, .bottom]) } @MapContentBuilder From 5ad2c8935eed9ccd67730d1f00fd9230ded334d6 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 15:29:43 -0700 Subject: [PATCH 35/71] Hold the readout still when the wrist drops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At full luminance the readout sits below the navigation bar that hosts the map/readout toggle. Hardware hides that bar in Always-On, so the same surface rendered several points higher — the shift Adam saw when comparing the two. Rather than encode which way it moves, the readout now measures the full-luminance bar and restores only what disappears: `max(0, latched - current)`. Equal insets give zero and nothing moves; a missing reference is deliberately a no-op, the same stance the panel takes when its own measurement is absent. Two details that are easy to get wrong and were both measured, not reasoned. The inset settles *upward* — 28.0 then 35.5 on 40 mm, 44.5 then 59.75 on 46 mm — as the bar grows to fit its toolbar item, so the reference is the largest value seen, not the first. First-nonzero would have under-corrected by 7.5 pt and 15.25 pt respectively, which reads as "nearly fixed" rather than as a bug. And the map reports a different inset again (47.5 and 62.0) because it extends beneath the top chrome, so only the readout may set the reference; borrowing the map's would push the readout down past where it belongs. The top rule therefore differs from the bottom's first-nonzero latch on purpose. The bottom value drives padding and can feed back into the measurement that produced it; this one drives a visual offset that cannot, so tracking a maximum is safe here and not there. Full-luminance readout measured unmoved to 0.00 pt on both sizes. The Always-On half is verifiable only on hardware. --- ios/MeshMapperWatch/MapPage.swift | 59 ++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 3ec0e4e..19171ea 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -67,6 +67,8 @@ struct MapPage: View { /// The panel's frame in global coordinates, so the camera can keep the fix /// out from behind it. @State private var panelFrame: CGRect = .zero + @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 @@ -147,16 +149,37 @@ struct MapPage: View { .background( GeometryReader { geo in Color.clear - .onAppear { latchBottomSafeAreaInset(geo.safeAreaInsets.bottom) } + .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 first - // nonzero value is latched before any dependent panel geometry can feed - // back into layout. The display's actual safe area is a device constant - // that cannot legitimately change when the selected content does. + // 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 { @@ -246,9 +269,21 @@ struct MapPage: View { // 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. + // A visual offset keeps this correction out of the geometry proposal + // being measured above, closing the layout feedback loop. + .offset(y: readoutTopOffset) } } + 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. @@ -349,6 +384,20 @@ struct MapPage: View { 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 this value drives a visual offset that cannot affect geometry. + 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, From 00759dc86bb33ba0b730a5127a34b773aa8ea037 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 15:45:12 -0700 Subject: [PATCH 36/71] Take back the height Always-On returns, not just the position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous correction moved the readout with `.offset(y:)`, which repositions without consuming space. Always-On hands back the hidden navigation bar's height, so the container grew by that much and the `Spacer(minLength: 6)` between the phase block and Top Heard absorbed the slack. The top-anchored phase block landed correctly and Top Heard was pushed down — which is exactly what Adam saw: headers matching, Top Heard low. Applying the same value as `.padding(.top,)` consumes the returned height as well as setting the origin, so the content box matches the full-luminance reference in size and position. The spacer then has the same slack it has at full luminance, which is none, and Top Heard packs where it does there. Padding a descendant cannot disturb the measurement: the reader takes its safe-area inset from the navigation host, which our padding does not feed. That is also what still permits the top reference to track a maximum while the bottom latches its first nonzero value. Full-luminance readout measured unmoved to 0.00 pt on both sizes again. --- ios/MeshMapperWatch/MapPage.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 19171ea..bd2249b 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -273,9 +273,11 @@ struct MapPage: View { // 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. - // A visual offset keeps this correction out of the geometry proposal - // being measured above, closing the layout feedback loop. - .offset(y: readoutTopOffset) + // 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) } } @@ -393,7 +395,8 @@ struct MapPage: View { // 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 this value drives a visual offset that cannot affect geometry. + // 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) } From 48aa208b3961fef6cfe6187c3dbc4ca12882924a Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 16:09:33 -0700 Subject: [PATCH 37/71] Start the map at 250 m, and stop MapKit overwriting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam asked for a much tighter starting zoom. The default drops from 0.0045 to 0.00225 degrees latitude — about 250 m north-south — with a one-time `map.zoomDefaultsVersion` migration, because the wearer's zoom is remembered and every watch that has shown the map already has a stored value that would otherwise win. The constant alone would not have worked. `noteRenderedRegion` guarded only on `programmaticCenter != nil`, which proves a region was requested, not that MapKit rendered it. MapKit reports its `.automatic` annotation fit for several callbacks after our first assignment, and those spans were persisted as though the wearer had chosen them. A fresh install stored 0.136 degrees on 40 mm and 0.0875 on 46 mm — 15 km and 9.7 km — and read that back on the next launch, so the map started wide and stayed there. That is almost certainly the behaviour being reported, and it would have eaten the new default within seconds. The centre had already been given this treatment: nothing counts as a pan until MapKit confirms a centre we asked for. The span now gets the same handshake. Traced on a fresh install: three automatic callbacks at 0.136 rejected at 59x the request, then a rendered 0.00225 confirms, after which callbacks reach the normal write-back and decline only because the value is unchanged. The confirmation tolerance is deliberately loose at 25%. It separates our request from a fit dozens of times wider, and an exact test could wait forever if MapKit adjusted a span for display geometry, silently disabling zoom memory — a quieter failure than the one being fixed. Fresh install and warm relaunch both hold 0.00225 on 40 mm and 46 mm. --- ios/MeshMapperWatch/MapPage.swift | 42 +++++++++++++++++++------ ios/MeshMapperWatch/WatchSettings.swift | 30 ++++++++++++------ 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index bd2249b..5107788 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -43,9 +43,20 @@ struct MapPage: View { /// `noteCameraChange`. @State private var hasConfirmedRequestedCenter = 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. + @State private var hasConfirmedRequestedSpan = false + /// Metres of disagreement before a camera change counts as a real pan. private static let panTolerance: CLLocationDistance = 40 + /// 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 + /// How long a pan pauses following before the map drifts back to the fix. private static let resumeFollowAfter: TimeInterval = 8 @@ -738,8 +749,9 @@ struct MapPage: View { } /// MapKit fits longitude to the watch's aspect ratio, so latitude is the one - /// independent zoom value. A fresh install starts at 0.0045 degrees, about - /// 500 m north-south, and later launches reuse the wearer's Crown setting. + /// 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, @@ -756,19 +768,29 @@ struct MapPage: View { /// starting point. private func noteRenderedRegion(_ region: MKCoordinateRegion) { renderedCenter = region.center - // Before our first camera update this region belongs to `.automatic`, - // which fits every annotation and can span a continent. It is not a wearer - // choice and must never become the remembered zoom. Once we have driven - // the camera, rendered spans include our requested value and later Digital - // Crown changes, both of which should persist. + // `.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 } + + 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. - let rendered = region.span.latitudeDelta - let stored = settings.mapLatitudeDelta - guard stored > 0, abs(rendered - stored) / stored > 0.01 else { return } + guard abs(rendered - requested) / requested > 0.01 else { return } settings.mapLatitudeDelta = rendered } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index e09b465..497acbc 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -13,13 +13,15 @@ final class WatchSettings { static let showLinks = "map.showLinks" static let follow = "map.follow" static let mapLatitudeDelta = "map.latitudeDelta" + static let mapZoomDefaultsVersion = "map.zoomDefaultsVersion" static let mainPageContent = "layout.mainPageContent" static let nodeListPlacement = "layout.nodeListPlacement" } - /// Roughly 500 m north-south: one degree of latitude is about 111 km. - static let defaultMapLatitudeDelta = 0.0045 + /// Roughly 250 m north-south: one degree of latitude is about 111,320 m. + static let defaultMapLatitudeDelta = 0.00225 private static let mapLatitudeDeltaLimits = 0.0005...0.5 + private static let mapZoomDefaultsVersion = 1 /// Where the recently-responded list lives. /// @@ -68,13 +70,23 @@ final class WatchSettings { // 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 - // `double(forKey:)` turns absence into zero, which would silently select - // the minimum zoom. Preserve the distinction so fresh installs get the - // deliberate ~500 m default instead. - mapLatitudeDelta = Self.clampedMapLatitudeDelta( - defaults.object(forKey: Key.mapLatitudeDelta) as? Double - ?? Self.defaultMapLatitudeDelta - ) + // 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)) From 590e5b7dbce933fb0b41293a7e19a751d59b3022 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 16:53:19 -0700 Subject: [PATCH 38/71] Put session control in the watch's second corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top bar holds exactly one item per side — a second leading item is silently dropped, which I confirmed by rendering it — so the trailing slot is the only one left, and the system moves the clock to centre to make room. It now carries session control. Which control owns the slot follows stable facts, never the live ping gate: no session -> Start running, ping applicable + opted in -> Ping running, otherwise -> Stop That distinction is the point. `canManualPing` flickers with cooldowns, RX windows and discovery windows, so keying the slot on it would change the button's identity under a moving thumb and turn a ping into a stop. The phone now publishes `manualPingApplicable` — connected, TX permitted by the region, and no Active, Hybrid or Trace session running — which holds still for the duration of a session. Live availability only enables or disables, so a cooldown greys the ping button rather than replacing it. Ping cannot fire during Active or Hybrid at all; `isTxModeRunning` gates it in the shared availability rule and again in the phone's own button. So the slot correctly shows Stop there rather than a permanently dead ping. Stop and Ping take two taps within three seconds, reusing the arming pattern manual ping already had, because this button sits under the thumb on a surface that is also panned. Start does not confirm; starting is recoverable and stopping loses the session. Start now sends an explicit mode. The snapshot advertises which the zone permits, the wrist picks its default from those, and the phone revalidates and refuses rather than downgrading. Settings gains Default start mode, defaulting to Passive with Hybrid offered only where it is permitted, and states plainly when a stored Hybrid cannot be honoured. Main page and Node list move above Map, so the choice that decides whether the map toggles matter is no longer below them. All six slot states driven through sample data on both watch sizes. `effectiveStartMode` is currently computed in two places; consolidating it is the first item of the next change. --- ios/MeshMapperWatch/MapPage.swift | 152 ++++++++++++++++++ ios/MeshMapperWatch/SampleSnapshot.swift | 40 ++++- ios/MeshMapperWatch/SettingsPage.swift | 52 +++++- ios/MeshMapperWatch/WatchSessionClient.swift | 3 +- ios/MeshMapperWatch/WatchSettings.swift | 47 ++++++ ios/Shared/MeshMapperWatchPayload.swift | 138 ++++++++++++++++ lib/providers/app_state_provider.dart | 31 +++- lib/services/watch/watch_bridge_service.dart | 7 +- lib/services/watch/watch_models.dart | 72 +++++++++ .../watch/watch_wire_contract_test.dart | 126 +++++++++++++-- 10 files changed, 641 insertions(+), 27 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 5107788..8cb8871 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -74,6 +74,22 @@ struct MapPage: View { } @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 + } + } + } /// The panel's frame in global coordinates, so the camera can keep the fix /// out from behind it. @@ -155,6 +171,9 @@ struct MapPage: View { ToolbarItem(placement: .topBarLeading) { mainPageToggle } + ToolbarItem(placement: .topBarTrailing) { + trailingToolbarButton + } } } .background( @@ -208,6 +227,18 @@ struct MapPage: View { } #endif } + .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 + if reduced { disarmToolbarControl() } + } + .onDisappear { disarmToolbarControl() } } private var pageContent: some View { @@ -243,6 +274,127 @@ struct MapPage: View { : "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 { + let available = client.snapshot?.availableStartModes ?? ["passive"] + return available.contains(settings.defaultStartMode.rawValue) + ? settings.defaultStartMode + : .passive + } + + private var trailingToolbarButton: some View { + let control = trailingToolbarControl + let pending = client.pendingCommand == control.command + let armed = armedToolbarControl == control + + return Button { + handleToolbarControl(control) + } label: { + ZStack { + if pending { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: toolbarIcon(for: control, armed: armed)) + } + } + .frame(width: 18, height: 18) + } + .tint(toolbarTint(for: control, armed: armed)) + .disabled(!trailingToolbarControlIsEnabled) + .accessibilityLabel(toolbarAccessibilityLabel(for: control, armed: armed)) + } + + 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 { + 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 toolbarTint( + for control: TrailingToolbarControl, + armed: Bool + ) -> Color { + guard trailingToolbarControlIsEnabled else { return WatchPalette.disabled } + if armed { return WatchPalette.armed } + switch control { + case .start: return WatchPalette.start + case .stop: return WatchPalette.stop + case .ping: return WatchPalette.ping + } + } + + private func toolbarAccessibilityLabel( + for control: TrailingToolbarControl, + armed: Bool + ) -> String { + 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 mapContent(_ proxy: MapProxy) -> some View { ZStack { map(proxy) diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 474890f..5dcca5c 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -12,8 +12,10 @@ import Foundation /// Pass `-MeshMapperSamplePhase listen|wait|lapsed` to exercise the live /// countdown, the between-cycle wait, or a deadline the phone did not replace. /// Listening is the default so existing capture commands keep their behaviour. -/// Pass `-MeshMapperSampleControls active|idle|blocked|cooldown` to review each -/// controls page state; active is the default. +/// Pass +/// `-MeshMapperSampleControls active|idle|blocked|cooldown|passiveOnly|txActive` +/// to review Start, live Ping, disabled-but-stable Ping, and both reasons Stop +/// keeps the slot; active is the default. /// /// DEBUG-only, and never reached unless that argument is passed, so it cannot /// leak into a shipping build or mask a real transport failure. @@ -40,13 +42,17 @@ enum SampleSnapshot { samplePhase = ("listening", "Listening…", now + 42_000, 60_000) } + let sampleControlState = UserDefaults.standard.string( + forKey: "MeshMapperSampleControls" + ) let sampleControls: WatchControls - switch UserDefaults.standard.string(forKey: "MeshMapperSampleControls") { + switch sampleControlState { case "idle": sampleControls = WatchControls( canStartStop: true, canManualPing: false, isSessionActive: false, + manualPingApplicable: true, manualCooldownEndsAtMs: nil, blockedReason: nil ) @@ -55,6 +61,7 @@ enum SampleSnapshot { canStartStop: false, canManualPing: false, isSessionActive: false, + manualPingApplicable: false, manualCooldownEndsAtMs: nil, blockedReason: "This zone is currently passive-only" ) @@ -64,14 +71,34 @@ enum SampleSnapshot { 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 ) @@ -144,7 +171,9 @@ enum SampleSnapshot { return WatchSnapshot( wireVersion: MeshMapperWatchWire.version, sessionId: "sample", - mode: "Active", + // 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", @@ -158,6 +187,9 @@ enum SampleSnapshot { traceCount: 0, queueSize: 2, pingColor: green, + availableStartModes: sampleControlState == "passiveOnly" + ? ["passive"] + : ["passive", "hybrid"], geo: WatchGeo( you: WatchPosition( lat: originLat + 0.006, diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 12becf0..6d899ff 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -6,18 +6,26 @@ import SwiftUI /// 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 { + availableStartModes.contains(settings.defaultStartMode) + ? settings.defaultStartMode + : .passive + } var body: some View { @Bindable var settings = settings List { - Section("Map") { - Toggle("Satellite", isOn: $settings.satellite) - Toggle("Follow position", isOn: $settings.follow) - Toggle("Lines to repeaters", isOn: $settings.showLinks) - } - - Section("Layout") { + Section("Display") { Picker("Main page", selection: $settings.mainPageContent) { ForEach(WatchSettings.MainPageContent.allCases) { content in Text(content.label).tag(content) @@ -29,6 +37,36 @@ struct SettingsPage: View { } } } + + Section("Map") { + Toggle("Satellite", isOn: $settings.satellite) + 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 + ) + } } .font(.caption) } diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 7fac894..a00ee65 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -94,7 +94,7 @@ final class WatchSessionClient: NSObject { /// 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, silent: Bool = false) { + func send(_ kind: WatchCommand.Kind, mode: String? = nil, silent: Bool = false) { guard let session, session.activationState == .activated else { if !silent { setLastRefusal("Not connected to iPhone") } return @@ -102,6 +102,7 @@ final class WatchSessionClient: NSObject { let command = WatchCommand( kind: kind, + mode: mode, id: UUID().uuidString, issuedAtMs: Date().timeIntervalSince1970 * 1000 ) diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 497acbc..a47b952 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -16,6 +16,8 @@ final class WatchSettings { static let mapZoomDefaultsVersion = "map.zoomDefaultsVersion" static let mainPageContent = "layout.mainPageContent" static let nodeListPlacement = "layout.nodeListPlacement" + static let defaultStartMode = "controls.defaultStartMode" + static let showPingWhenAvailable = "controls.showPingWhenAvailable" } /// Roughly 250 m north-south: one degree of latitude is about 111,320 m. @@ -61,6 +63,22 @@ final class WatchSettings { } } + /// 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) { @@ -91,6 +109,11 @@ final class WatchSettings { .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 } /// Apple imagery rather than the standard basemap. Mirrors the iOS app's @@ -133,12 +156,36 @@ final class WatchSettings { 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) } + } + 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. diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index c521a8d..02633f5 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -24,6 +24,11 @@ enum MeshMapperWatchWire { /// 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. @@ -126,10 +131,51 @@ 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 @@ -181,11 +227,100 @@ struct WatchSnapshot: Codable, Hashable { /// 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] + 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"], + 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.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, 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"] + 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 } @@ -223,6 +358,9 @@ struct WatchCommand: Codable, Hashable { } 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? /// Client-generated, so the phone can dedupe redelivered commands. let id: String /// Queued delivery can outlive the place where a transmit was requested. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d144e37..457cb33 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1286,6 +1286,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { core: core, geo: _buildWatchGeo(), controls: _buildWatchControls(), + availableStartModes: _availableWatchStartModes, pingColor: pingColor, cue: _watchCue, phaseDurationMs: phaseDurationMs, @@ -1473,6 +1474,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return _autoMode; } + List get _availableWatchStartModes => [ + WatchStartMode.passive, + if (isConnected && txAllowed) WatchStartMode.hybrid, + ]; + String get _resolvedWatchSessionModeTitle => switch (_resolvedWatchSessionMode) { AutoMode.active => 'Active', @@ -1489,6 +1495,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { canStartStop: isConnected, 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, @@ -1519,9 +1533,11 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { /// 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(WatchCommandKind kind) { + String? _handleWatchCommand(WatchCommand command) { if (_isDisposed) return 'App closing'; + final kind = command.kind; + switch (kind) { case WatchCommandKind.requestSnapshot: _scheduleWatchSync(immediate: true); @@ -1536,7 +1552,18 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { if (admission.refusal != null) return admission.refusal; if (!admission.shouldRun) return null; if (!isConnected) return 'Not connected'; - unawaited(_runWatchStartSession(_resolvedWatchSessionMode)); + 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, + }; + unawaited(_runWatchStartSession(mode)); return null; case WatchCommandKind.stopSession: diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 67284e4..ee6211f 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -14,7 +14,7 @@ typedef WatchUrgencyKeyBuilder = String Function(); /// reason when refused; admitted work continues independently of this reply. /// Production handlers must decide synchronously; FutureOr keeps existing /// bridge fakes source-compatible without putting the real path behind a wait. -typedef WatchCommandHandler = FutureOr Function(WatchCommandKind kind); +typedef WatchCommandHandler = FutureOr Function(WatchCommand command); typedef WatchCommandRefusalHandler = void Function(String reason); typedef WatchAvailabilityHandler = void Function(bool available); typedef WatchSnapshotDeliveryHandler = void Function(WatchSnapshot snapshot); @@ -130,7 +130,10 @@ class WatchBridgeService { // what makes the MethodChannel response fit inside WatchConnectivity's // short reply window; the admitted action reports its later outcome via // normal snapshots and one-shot cues. - final admission = handler(kind); + final admission = handler(WatchCommand( + kind: kind, + mode: args['mode'] as String?, + )); final refusal = admission is Future ? await admission : admission; if (refusal != null) { diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 2d4a9f3..2e9df65 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -23,6 +23,12 @@ class WatchWire { /// v2: heard nodes mirror the app's "Top Heard" map overlay — hex ID and /// ping-type colour — instead of the richer per-echo data. Hop counts are /// gone: the overlay is fed `directRepeaters` only. + /// + /// Additive optional fields do not bump this version. A new watch defaults + /// an absent start-mode list to Passive and absent Ping applicability to + /// false, while an older phone ignores the optional mode on a command and + /// retains its existing safe resolver. A bump would therefore strand + /// compatible pairs without preventing a bad decode. static const int version = 2; static const int maxPings = 60; @@ -37,6 +43,23 @@ class WatchWire { static const double minMoveMeters = 15.0; } +/// Start modes the phone may explicitly offer to the wrist. +/// +/// Active remains a phone-only choice. The wrist setting deliberately stays +/// small: Passive is safe everywhere, while Hybrid is advertised only when +/// current zone policy permits transmission. +enum WatchStartMode { + passive, + hybrid; + + static WatchStartMode? fromWire(String value) { + for (final mode in WatchStartMode.values) { + if (mode.name == value) return mode; + } + return null; + } +} + class WatchPosition { const WatchPosition({ required this.lat, @@ -203,6 +226,7 @@ class WatchControls { required this.canStartStop, required this.canManualPing, required this.isSessionActive, + this.manualPingApplicable = false, this.manualCooldownEndsAt, this.blockedReason, }); @@ -210,6 +234,11 @@ class WatchControls { final bool canStartStop; final bool canManualPing; final bool isSessionActive; + + /// Stable ownership for the corner slot. Unlike [canManualPing], this does + /// not flicker during cooldowns or receive windows; those only disable the + /// ping control that already owns the slot. + final bool manualPingApplicable; final DateTime? manualCooldownEndsAt; final String? blockedReason; @@ -217,6 +246,7 @@ class WatchControls { 'canStartStop': canStartStop, 'canManualPing': canManualPing, 'isSessionActive': isSessionActive, + 'manualPingApplicable': manualPingApplicable, 'manualCooldownEndsAtMs': manualCooldownEndsAt?.millisecondsSinceEpoch.toDouble(), 'blockedReason': blockedReason, @@ -264,6 +294,7 @@ class WatchSnapshot { required this.geo, required this.controls, required this.updatedAt, + this.availableStartModes = const [WatchStartMode.passive], this.pingColor, this.cue, this.phaseDurationMs, @@ -273,6 +304,7 @@ class WatchSnapshot { final LiveActivitySnapshot core; final WatchGeo geo; final WatchControls controls; + final List availableStartModes; final WatchColor? pingColor; final WatchHapticCue? cue; final DateTime updatedAt; @@ -301,6 +333,8 @@ class WatchSnapshot { 'traceCount': core.traceCount, 'queueSize': core.queueSize, 'pingColor': pingColor?.toMap(), + 'availableStartModes': + availableStartModes.map((mode) => mode.name).toList(), 'geo': geo.toMap(), 'controls': controls.toMap(), 'cue': cue?.toMap(), @@ -345,6 +379,7 @@ class WatchSnapshot { controls.canStartStop, controls.canManualPing, controls.isSessionActive, + controls.manualPingApplicable, cue?.id ?? '', ].join('|'); } @@ -364,6 +399,43 @@ enum WatchCommandKind { } } +/// Decoded wrist intent. [mode] stays raw until phone-side admission so an +/// unknown value can be refused rather than mistaken for an omitted mode. +class WatchCommand { + const WatchCommand({required this.kind, this.mode}); + + final WatchCommandKind kind; + final String? mode; +} + +typedef WatchRequestedStartModeResolution = ({ + WatchStartMode? mode, + String? refusal, +}); + +/// Revalidate an explicit wrist mode against current phone state. +/// +/// A null result means an older watch omitted the field and the provider must +/// retain its established `_resolvedWatchSessionMode` fallback. An unsupported +/// or newly-forbidden request is never downgraded silently. +WatchRequestedStartModeResolution resolveWatchRequestedStartMode({ + required String? requestedMode, + required bool isConnected, + required bool txAllowed, +}) { + if (requestedMode == null) return (mode: null, refusal: null); + + final mode = WatchStartMode.fromWire(requestedMode); + if (mode == null) return (mode: null, refusal: 'Unsupported start mode'); + if (mode == WatchStartMode.hybrid && !isConnected) { + return (mode: null, refusal: 'Not connected'); + } + if (mode == WatchStartMode.hybrid && !txAllowed) { + return (mode: null, refusal: 'Passive Only'); + } + return (mode: mode, refusal: null); +} + typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); /// Resolve the wrist's single Start/Stop control without racing the phone's diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index e30a32b..d0a67cd 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -18,6 +18,7 @@ WatchSnapshot _snapshot({ String phaseTitle = 'Listening', bool isConnected = true, int? phaseDurationMs, + List availableStartModes = const [WatchStartMode.passive], }) => WatchSnapshot( core: LiveActivitySnapshot( @@ -50,6 +51,7 @@ WatchSnapshot _snapshot({ canManualPing: false, isSessionActive: true, ), + availableStartModes: availableStartModes, pingColor: const WatchColor(1, 0, 0), phaseDurationMs: phaseDurationMs, cue: cue, @@ -80,6 +82,7 @@ void main() { 'traceCount', 'queueSize', 'pingColor', + 'availableStartModes', 'geo', 'controls', 'cue', @@ -101,6 +104,7 @@ void main() { 'canStartStop', 'canManualPing', 'isSessionActive', + 'manualPingApplicable', 'manualCooldownEndsAtMs', 'blockedReason', }, @@ -196,6 +200,40 @@ void main() { expect(_snapshot().toMap()['phaseDurationMs'], isNull); }); + test('snapshot carries phone-resolved start modes', () { + expect( + _snapshot( + availableStartModes: const [ + WatchStartMode.passive, + WatchStartMode.hybrid, + ], + ).toMap()['availableStartModes'], + ['passive', 'hybrid'], + ); + }); + + test('old controls default manual ping slot ownership to false', () { + const controls = WatchControls( + canStartStop: true, + canManualPing: true, + isSessionActive: true, + ); + + expect(controls.manualPingApplicable, isFalse); + expect(controls.toMap()['manualPingApplicable'], isFalse); + }); + + test('controls serialize stable manual ping slot ownership', () { + const controls = WatchControls( + canStartStop: true, + canManualPing: false, + isSessionActive: true, + manualPingApplicable: true, + ); + + expect(controls.toMap()['manualPingApplicable'], isTrue); + }); + test('wire version is stamped so the watch can refuse unknown payloads', () { expect(_snapshot().toMap()['wireVersion'], WatchWire.version); @@ -276,6 +314,27 @@ void main() { LiveActivityPhase.starting, ); }); + + test('Hybrid is refused when current zone policy forbids TX', () { + final result = resolveWatchRequestedStartMode( + requestedMode: 'hybrid', + isConnected: true, + txAllowed: false, + ); + + expect(result.mode, isNull); + expect(result.refusal, 'Passive Only'); + }); + + test('an omitted start mode preserves the established phone fallback', () { + final result = resolveWatchRequestedStartMode( + requestedMode: null, + isConnected: true, + txAllowed: false, + ); + + expect(result, (mode: null, refusal: null)); + }); }); group('bridge command handling', () { @@ -319,13 +378,21 @@ void main() { .setMockMethodCallHandler(channel, null); }); - Future?> sendCommand(String id, String kind) async { + Future?> sendCommand( + String id, + String kind, { + String? mode, + }) async { final result = await TestDefaultBinaryMessengerBinding .instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - MethodCall('command', {'id': id, 'kind': kind}), + MethodCall('command', { + 'id': id, + 'kind': kind, + if (mode != null) 'mode': mode, + }), ), null, ); @@ -477,8 +544,8 @@ void main() { }); test('accepted commands reach the handler', () async { - bridge.attachCommandHandler((kind) async { - handled.add(kind); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); return null; }); @@ -489,8 +556,8 @@ void main() { }); test('a redelivered command does not transmit twice', () async { - bridge.attachCommandHandler((kind) async { - handled.add(kind); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); return null; }); @@ -503,8 +570,8 @@ void main() { test('a refused command may be retried once conditions change', () async { var refuse = true; - bridge.attachCommandHandler((kind) async { - handled.add(kind); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); return refuse ? 'Not connected' : null; }); @@ -520,8 +587,8 @@ void main() { test('an unknown command is refused without reaching the handler', () async { - bridge.attachCommandHandler((kind) async { - handled.add(kind); + bridge.attachCommandHandler((command) async { + handled.add(command.kind); return null; }); @@ -533,12 +600,49 @@ void main() { test('a handler that throws refuses rather than crashing the bridge', () async { - bridge.attachCommandHandler((kind) async => throw StateError('boom')); + bridge.attachCommandHandler((command) async => throw StateError('boom')); final reply = await sendCommand('cmd-4', 'manualPing'); expect(reply?['accepted'], isFalse); expect(reply?['reason'], 'Command failed'); }); + + test('a start command carries its requested mode to admission', () async { + WatchCommand? received; + bridge.attachCommandHandler((command) { + received = command; + return null; + }); + + final reply = await sendCommand( + 'cmd-mode', + 'startSession', + mode: 'hybrid', + ); + + expect(reply?['accepted'], isTrue); + expect(received?.kind, WatchCommandKind.startSession); + expect(received?.mode, 'hybrid'); + }); + + test('a forbidden requested mode returns the phone refusal', () async { + bridge.attachCommandHandler((command) { + return resolveWatchRequestedStartMode( + requestedMode: command.mode, + isConnected: true, + txAllowed: false, + ).refusal; + }); + + final reply = await sendCommand( + 'cmd-forbidden-mode', + 'startSession', + mode: 'hybrid', + ); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Passive Only'); + }); }); } From e1adacbbfcab69ea64c81b3d85efc75c8edf1ce9 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 17:19:42 -0700 Subject: [PATCH 39/71] Make the controls page look like the rest of MeshMapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It used raw system tints and a bare stack of two buttons, which is most of why it read as a page from a different app. It now uses the phone's own vocabulary: slate surfaces, green #22C55E, the app's red #BD2130, indigo #6366F1, a 12 pt material card with a hairline border matching the map's status panel, and the readout's approved type scale. Three substantive changes beyond paint. A compact mode and phase header, because the page previously gave no way to see what was running while looking at the controls for it. `blockedReason` and refusals now sit directly beneath the control they explain rather than floating at the bottom as two grey strings. And the phase shrinks to fit rather than truncating — on 40 mm it rendered "Listenin…", and since the titles the phone sends already end in an ellipsis, that read as a rendering fault. The controls were 69.5 pt tall against a 44 pt ergonomic floor, which pushed the last one to within 5 pt of the 46 mm display bottom. At the card's 14 pt margin and that watch's ~37 pt corner radius the curve needs about 8 pt, so its corners were clipped — the failure mode this project has hit repeatedly, and again invisible in a flat simulator rectangle until measured. Moving the 44 pt guarantee to the styled button rather than its label brings them to 52 pt on 46 mm and 44.5 pt on 40 mm. Every sample state now fits both displays without scrolling, with 40 pt and 16 pt of clearance in the tightest one. `effectiveStartMode` is now single-sourced on `WatchSettings`; the map and settings copies had already begun to diverge in form. The toolbar's trailing control drops its capsule fill for a coloured glyph on the default glass, matching the leading toggle it sits opposite. Only the armed state stays filled: the confirm window is the one state with a consequence attached, so it is the one that should be loud. One defect fixed along the way. Associating refusals with their originating control had routed phone-originated cues through the same path, so an unrelated cue inherited whatever the wrist last tapped and could report a failure under Manual ping for a ping that succeeded. `WatchHapticCue` carries no correlation to a command, so attribution is now explicit per call site and cues are deliberately unattributed. --- ios/MeshMapperWatch/ControlsPage.swift | 168 ++++++++++++++++--- ios/MeshMapperWatch/MapPage.swift | 39 +++-- ios/MeshMapperWatch/SettingsPage.swift | 6 +- ios/MeshMapperWatch/WatchSessionClient.swift | 28 +++- ios/MeshMapperWatch/WatchSettings.swift | 13 ++ 5 files changed, 208 insertions(+), 46 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index d891f69..66a89f0 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -11,6 +11,9 @@ struct ControlsPage: View { @State private var pingArmed = false @State private var disarmPingTask: Task? + private static let minimumTapHeight: CGFloat = 44 + private static let compactLabelHeight: CGFloat = 24 + private var controls: WatchControls? { client.snapshot?.controls } /// Manual-ping cooldown deadline, if one is still ahead of us. @@ -24,27 +27,37 @@ struct ControlsPage: 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. - VStack(spacing: 10) { - startStopButton - manualPingButton - - if let reason = controls?.blockedReason { - Text(reason) - .font(.caption2) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } + // 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 - if let refusal = client.lastRefusal { - Text(refusal) - .font(.caption2) - .foregroundStyle(.orange) - .multilineTextAlignment(.center) + // 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, 8) - .padding(.top, 4) + .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. @@ -60,6 +73,97 @@ struct ControlsPage: View { .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: 10, 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: 10, 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 @@ -74,10 +178,10 @@ struct ControlsPage: View { client.send(kind) } label: { Text(buttonTitle) - .font(.headline) + .font(.system(size: 13, weight: .semibold)) .lineLimit(1) .truncationMode(.tail) - .frame(maxWidth: .infinity, minHeight: 44) + .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. @@ -91,9 +195,15 @@ struct ControlsPage: View { } } .buttonStyle(.borderedProminent) - // Grey when unavailable rather than a desaturated tint: a disabled green - // renders pale enough to read as a live button worth tapping. - .tint(isEnabled ? (isActive ? .red : .green) : .gray) + // 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) } @@ -110,7 +220,7 @@ struct ControlsPage: View { } } label: { pingLabel(isPending: isPending) - .frame(maxWidth: .infinity, minHeight: 44) + .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) { @@ -123,7 +233,11 @@ struct ControlsPage: View { } } .buttonStyle(.borderedProminent) - .tint(isEnabled ? (pingArmed ? .orange : .accentColor) : .gray) + .frame(minHeight: Self.minimumTapHeight) + .buttonBorderShape(.roundedRectangle(radius: WatchPalette.cornerRadius)) + .tint(isEnabled + ? (pingArmed ? WatchPalette.armed : WatchPalette.ping) + : WatchPalette.disabled) .disabled(!isEnabled) } @@ -147,10 +261,10 @@ struct ControlsPage: View { .monospacedDigit() .frame(width: 38, alignment: .leading) } - .font(.headline) + .font(.system(size: 13, weight: .semibold)) } else { Text(isPending ? "Sending…" : (pingArmed ? "Send ping?" : "Manual ping")) - .font(.headline) + .font(.system(size: 13, weight: .semibold)) } } diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 8cb8871..9711022 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -298,33 +298,44 @@ struct MapPage: View { } private var effectiveStartMode: WatchSettings.DefaultStartMode { - let available = client.snapshot?.availableStartModes ?? ["passive"] - return available.contains(settings.defaultStartMode.rawValue) - ? settings.defaultStartMode - : .passive + 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 - return Button { + 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) } - .tint(toolbarTint(for: control, armed: armed)) .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) { @@ -354,12 +365,9 @@ struct MapPage: View { } } - private func toolbarTint( - for control: TrailingToolbarControl, - armed: Bool + private func toolbarActionColor( + for control: TrailingToolbarControl ) -> Color { - guard trailingToolbarControlIsEnabled else { return WatchPalette.disabled } - if armed { return WatchPalette.armed } switch control { case .start: return WatchPalette.start case .stop: return WatchPalette.stop @@ -367,6 +375,15 @@ struct MapPage: View { } } + private func toolbarGlyphColor( + for control: TrailingToolbarControl, + armed: Bool + ) -> Color { + guard trailingToolbarControlIsEnabled else { return WatchPalette.disabled } + if armed { return .white } + return toolbarActionColor(for: control) + } + private func toolbarAccessibilityLabel( for control: TrailingToolbarControl, armed: Bool diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 6d899ff..929a333 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -16,9 +16,9 @@ struct SettingsPage: View { } private var effectiveStartMode: WatchSettings.DefaultStartMode { - availableStartModes.contains(settings.defaultStartMode) - ? settings.defaultStartMode - : .passive + settings.effectiveStartMode( + availableStartModes: client.snapshot?.availableStartModes + ) } var body: some View { diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index a00ee65..d11fa2b 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -24,6 +24,11 @@ final class WatchSessionClient: NSObject { /// 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. @@ -96,7 +101,9 @@ final class WatchSessionClient: NSObject { /// duplicate-transmit risk of retrying an ambiguously delivered message. func send(_ kind: WatchCommand.Kind, mode: String? = nil, silent: Bool = false) { guard let session, session.activationState == .activated else { - if !silent { setLastRefusal("Not connected to iPhone") } + if !silent { + setLastRefusal("Not connected to iPhone", from: kind) + } return } @@ -109,12 +116,14 @@ final class WatchSessionClient: NSObject { 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") } + if !silent { + setLastRefusal("Could not encode command", from: kind) + } return } if !silent { - setLastRefusal(nil) + setLastRefusal(nil, from: kind) beginPending(kind) } @@ -138,16 +147,21 @@ final class WatchSessionClient: NSObject { pendingCommand = nil } - private func setLastRefusal(_ refusal: String?) { + 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 } } @@ -216,7 +230,11 @@ final class WatchSessionClient: NSObject { self.presentedCueIDs.remove(self.presentedCueIDOrder.removeFirst()) } if let message = cue.message, !message.isEmpty { - self.setLastRefusal(message) + // 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. + self.setLastRefusal(message, from: nil) } } } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index a47b952..28cb3d4 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -164,6 +164,19 @@ final class WatchSettings { didSet { defaults.set(showPingWhenAvailable, forKey: Key.showPingWhenAvailable) } } + /// 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) From 3b9b89513830943d1cab5bac8fc91ea6efbc6e9e Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 17:56:04 -0700 Subject: [PATCH 40/71] Let the map page be a page: swipe navigates, crown zooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from Adam's walk. The trailing control looked empty with nothing connected. It was drawn correctly — a disabled play glyph in slate on glass — but slate over Apple's darkest basemap is invisible, so it read as an empty circle. Disconnection now has its own glyph rather than a dimmed Start, and the disabled colour moves to the brighter slate so that no disabled control reads as empty. The accessibility label still distinguishes a missing phone from a missing device even though the glyph cannot. Satellite did nothing. `.mapStyle(.imagery)` renders pixel-identically to `.standard` on watchOS: a diff of the two states across the whole basemap band found zero differing pixels, and it behaved the same on Adam's watch. The toggle is gone rather than left as a control that silently does nothing, and the header comment records it alongside the other watchOS MapKit limits. The map claimed both gestures. `interactionModes` included `.pan`, so vertical swipes dragged the basemap instead of paging and the status panel was the only place a swipe could change pages. Zoom-only gives the page back its swipe while leaving the crown as the deliberate zoom control. That removes the pan-suspension machinery entirely, and deliberately rather than incidentally. Its purpose was to stop fighting a wearer who had dragged the map. With drag gone, the only remaining source of centre drift is MapKit settling and crown zoom — neither of which is a wearer moving the map — so a distance heuristic could no longer identify a pan truthfully, only misfire and suspend follow for eight seconds. Keeping it would have introduced the bug this change was meant to avoid. `programmaticCenter` stays: it is now solely the first half of the span handshake that keeps `.automatic` from becoming the remembered zoom. Fresh installs still settle at 250 m on both watch sizes. --- ios/MeshMapperWatch/MapPage.swift | 121 ++++++------------------- ios/MeshMapperWatch/SettingsPage.swift | 1 - 2 files changed, 26 insertions(+), 96 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 9711022..dac44a4 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -9,6 +9,9 @@ import WatchKit /// `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 { @Environment(WatchSessionClient.self) private var client @Environment(WatchSettings.self) private var settings @@ -33,33 +36,22 @@ struct MapPage: View { @State private var camera: MapCameraPosition = .automatic - /// Centre we last drove the camera to, so a camera change can be attributed - /// to the wearer rather than to our own follow updates. + /// 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. @State private var programmaticCenter: CLLocationCoordinate2D? - @State private var followSuspendedUntil: Date? - - /// Whether MapKit has ever reported back a centre we asked for. Until it has, - /// a disagreeing centre is `.automatic` settling, not the wearer — see - /// `noteCameraChange`. - @State private var hasConfirmedRequestedCenter = 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. @State private var hasConfirmedRequestedSpan = false - /// Metres of disagreement before a camera change counts as a real pan. - private static let panTolerance: CLLocationDistance = 40 - /// 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 - /// How long a pan pauses following before the map drifts back to the fix. - private static let resumeFollowAfter: TimeInterval = 8 - private var snapshot: WatchSnapshot? { client.snapshot } private var fix: CLLocationCoordinate2D? { @@ -68,9 +60,7 @@ struct MapPage: View { } private var isFollowing: Bool { - guard settings.follow else { return false } - if let until = followSuspendedUntil, until > Date() { return false } - return true + settings.follow } @State private var showingNodes = false @@ -357,6 +347,12 @@ struct MapPage: View { 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" @@ -379,7 +375,10 @@ struct MapPage: View { for control: TrailingToolbarControl, armed: Bool ) -> Color { - guard trailingToolbarControlIsEnabled else { return WatchPalette.disabled } + // 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) } @@ -388,6 +387,8 @@ struct MapPage: View { 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" @@ -425,20 +426,9 @@ struct MapPage: View { .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in recenterIfFollowing(proxy) } - // The delayed resume in `scheduleFollowResume` only clears the suspension; - // recentring happens here, where a live proxy is in scope. - .onChange(of: followSuspendedUntil) { _, until in - if until == nil { recenterIfFollowing(proxy) } - } .onAppear { recenterIfFollowing(proxy) } - .onDisappear { - // A pan's delayed resume belongs to the map. Leaving for either the - // chosen readout or Always-On must not leave map work pending off-screen. - resumeTask?.cancel() - resumeTask = nil - } } private var readoutContent: some View { @@ -736,7 +726,6 @@ struct MapPage: View { private func recenterButton(_ proxy: MapProxy) -> some View { if !isFollowing, fix != nil { Button { - followSuspendedUntil = nil recenterIfFollowing(proxy, force: true) } label: { Image(systemName: "location.fill") @@ -751,22 +740,25 @@ struct MapPage: View { // MARK: - Map private func map(_ proxy: MapProxy) -> some View { - Map(position: $camera, interactionModes: [.pan, .zoom]) { + // 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(settings.satellite ? .imagery : .standard) + .mapStyle(.standard) .onMapCameraChange(frequency: .onEnd) { context in noteRenderedRegion(context.region) - noteCameraChange(context.region.center) correctPlacement(proxy) } // 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 - // transient trailing recentre button away from the leading toolbar control. + // inset recentre button below the system toolbar controls. .ignoresSafeArea(edges: [.top, .bottom]) } @@ -963,67 +955,6 @@ struct MapPage: View { settings.mapLatitudeDelta = rendered } - private func noteCameraChange(_ center: CLLocationCoordinate2D) { - guard let expected = programmaticCenter else { - // We have never driven the camera, so this is `.automatic` settling on - // launch rather than a pan. Treating it as one would suspend following - // before the first fix even arrives. - return - } - - let drift = distance(center, expected) - - // `.automatic` settles *after* our first request, centred on the annotation - // cloud — measured 372 m from the fix — and that disagreement is not a pan. - // Reading it as one suspended following for eight seconds at every launch, - // and overwrote the expectation, so our own region landing then looked like - // a second pan. Nothing counts as a pan until MapKit has confirmed a centre - // we actually asked for. - guard hasConfirmedRequestedCenter else { - if drift <= Self.panTolerance { hasConfirmedRequestedCenter = true } - return - } - - guard drift > Self.panTolerance else { - // Our own follow update landing. - return - } - - // The wearer moved the map. Stop fighting them, and drift back shortly. - programmaticCenter = center - let deadline = Date().addingTimeInterval(Self.resumeFollowAfter) - followSuspendedUntil = deadline - scheduleFollowResume(at: deadline) - } - - /// Re-evaluate when the suspension lapses. - /// - /// `followSuspendedUntil` is only read during a render, and a stationary - /// phone sends no updates to trigger one — without this the map would stay - /// unfollowed indefinitely after a single pan. - private func scheduleFollowResume(at deadline: Date) { - resumeTask?.cancel() - resumeTask = Task { @MainActor in - let seconds = deadline.timeIntervalSinceNow - if seconds > 0 { - try? await Task.sleep(for: .seconds(seconds)) - } - guard !Task.isCancelled, followSuspendedUntil == deadline else { return } - // Clearing this drives the recentre, via `onChange` where a proxy is in - // scope. - followSuspendedUntil = nil - } - } - - @State private var resumeTask: Task? - - private func distance( - _ a: CLLocationCoordinate2D, - _ b: CLLocationCoordinate2D - ) -> CLLocationDistance { - CLLocation(latitude: a.latitude, longitude: a.longitude) - .distance(from: CLLocation(latitude: b.latitude, longitude: b.longitude)) - } } /// A phase-scoped progress animation rather than a one-second render clock. diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 929a333..75972f2 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -39,7 +39,6 @@ struct SettingsPage: View { } Section("Map") { - Toggle("Satellite", isOn: $settings.satellite) Toggle("Follow position", isOn: $settings.follow) Toggle("Lines to repeaters", isOn: $settings.showLinks) } From 4a461da73161fb8047c31387aa82c0e36271e382 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 19:09:18 -0700 Subject: [PATCH 41/71] Stop the wrist from starting a session the phone would refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-beta review round one: four correctness defects, none cosmetic. The wrist could start a session the phone's own button would have refused. `canStartStop` checked connection and nothing else, while the phone gates Start on nine conditions. Starting Active from the wrist while a manual ping was in flight made `sendTxPing` return early on `_pingInProgress`, so the first automatic ping neither transmitted nor scheduled the next cycle: a session reporting itself active that never advanced. A tester could have walked an hour and come back with nothing. Start admission is now one rule, mirroring how `_manualPingAvailability` is already the single copy of the ping gate, and consumed by both the offered button and the radio admission. It is mode-aware on purpose: Passive does not transmit, so TX cooldowns, receive windows and zone TX policy must not block it — refusing a Passive start because a manual ping was in flight would be the same bug pointing the other way. `ping_controls.dart` keeps its own copy for now; its enablement is entangled with running-mode toggles and labels, and restructuring the phone UI days before a beta buys a worse risk than it removes. `DebugPage` shipped in Release with a single-tap manual ping, bypassing the confirmation the real surfaces have precisely because that action transmits. It is now DEBUG-only, and while it remains there its ping confirms and its Start sends the configured mode. Command failures had no presentation on the map. The toolbar is now the primary way to start and stop a session, but refusals only reached `ControlsPage`, so a wrist Start could fail, clear its spinner, and explain nothing on the surface being looked at. Both surfaces now show a transient toast on the existing six-second lifetime, layout-neutral and absent at reduced luminance. The readout anchors it to the safe-area child rather than to the black backing: that sibling ignores the safe area and would have placed a bottom-aligned overlay off-screen, the third time this file has been bitten by geometry read from an expanded region. Sized to clear the corner radius so it drops to 2.5 pt off the physical edge, it sits below Top Heard entirely on 46 mm. Controls sent no mode while the toolbar sent an explicit one, so the two made different promises — and on a fresh phone session in a TX region, ambient `_autoMode` can be Active while the wrist's stored default is Passive. Both now send the same single-sourced effective mode. One known limit, not worth wire churn before the beta: enablement is computed for Passive because the chosen mode is watch-local, so a blocked Hybrid start shows an enabled button and an explained refusal rather than a disabled one. Publishing availability per mode would fix it properly. `-MeshMapperForceRefusal` joins the DEBUG launch arguments, since the banner is otherwise unreachable in a simulator with no way to tap. --- ios/MeshMapperWatch/ContentView.swift | 4 + ios/MeshMapperWatch/ControlsPage.swift | 15 ++- ios/MeshMapperWatch/DebugPage.swift | 49 +++++++- ios/MeshMapperWatch/MapPage.swift | 118 ++++++++++++++++++ ios/MeshMapperWatch/SampleSnapshot.swift | 25 ++-- ios/MeshMapperWatch/WatchSessionClient.swift | 13 ++ lib/providers/app_state_provider.dart | 52 +++++++- lib/services/watch/watch_models.dart | 52 ++++++++ .../watch/watch_wire_contract_test.dart | 70 +++++++++++ 9 files changed, 380 insertions(+), 18 deletions(-) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index d5a01bc..f524c70 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -48,7 +48,11 @@ struct ContentView: View { .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) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index 66a89f0..585bd92 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -7,6 +7,7 @@ import SwiftUI /// 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? @@ -16,6 +17,12 @@ struct ControlsPage: View { 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 } @@ -169,13 +176,17 @@ struct ControlsPage: View { let kind: WatchCommand.Kind = isActive ? .stopSession : .startSession let isPending = client.pendingCommand == kind let isEnabled = controls?.canStartStop == true && !isPending - let startTitle = "Start \(client.snapshot?.mode ?? "Session")" + let startTitle = "Start \(effectiveStartMode.label)" let buttonTitle = isPending ? (isActive ? "Stopping…" : "Starting…") : (isActive ? "Stop" : startTitle) return Button { - client.send(kind) + if kind == .startSession { + client.send(kind, mode: effectiveStartMode.rawValue) + } else { + client.send(kind) + } } label: { Text(buttonTitle) .font(.system(size: 13, weight: .semibold)) diff --git a/ios/MeshMapperWatch/DebugPage.swift b/ios/MeshMapperWatch/DebugPage.swift index d48957a..e94a614 100644 --- a/ios/MeshMapperWatch/DebugPage.swift +++ b/ios/MeshMapperWatch/DebugPage.swift @@ -1,5 +1,6 @@ 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. @@ -7,6 +8,10 @@ import SwiftUI /// 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 { @@ -41,6 +46,7 @@ struct DebugPage: View { .padding(.horizontal, 4) .opacity(client.isStale ? 0.45 : 1.0) } + .onDisappear { disarmPing() } } private var header: some View { @@ -130,12 +136,23 @@ struct DebugPage: View { private var commandButtons: some View { VStack(spacing: 4) { - Button(sessionActive ? "Stop" : "Start") { - client.send(sessionActive ? .stopSession : .startSession) + 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("Manual ping") { client.send(.manualPing) } + 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) } @@ -147,4 +164,30 @@ struct DebugPage: View { 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/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index dac44a4..4b2461f 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -112,6 +112,7 @@ struct MapPage: View { /// 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 @@ -154,6 +155,17 @@ struct MapPage: View { 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 .toolbar { @@ -215,6 +227,12 @@ struct MapPage: View { 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) + } #endif } .onChange(of: trailingToolbarControl) { _, _ in @@ -246,6 +264,63 @@ struct MapPage: View { } } + 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 @@ -448,6 +523,38 @@ struct MapPage: View { // 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) + } + } } } @@ -467,6 +574,17 @@ struct MapPage: View { } 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 PanelFrameKey can move. + .alignmentGuide(.top) { dimensions in + dimensions[.bottom] + 3 + } + } .background( GeometryReader { geo in Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 5dcca5c..a998edb 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -9,16 +9,23 @@ import Foundation /// /// xcrun simctl launch net.meshmapper.app.watchkitapp -MeshMapperSampleData YES /// -/// Pass `-MeshMapperSamplePhase listen|wait|lapsed` to exercise the live -/// countdown, the between-cycle wait, or a deadline the phone did not replace. -/// Listening is the default so existing capture commands keep their behaviour. -/// Pass -/// `-MeshMapperSampleControls active|idle|blocked|cooldown|passiveOnly|txActive` -/// to review Start, live Ping, disabled-but-stable Ping, and both reasons Stop -/// keeps the slot; active is the default. +/// Other DEBUG launch arguments used by the capture harness: /// -/// DEBUG-only, and never reached unless that argument is passed, so it cannot -/// leak into a shipping build or mask a real transport failure. +/// - `-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. +/// +/// 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") diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index d11fa2b..d066265 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -147,6 +147,19 @@ final class WatchSessionClient: NSObject { 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? diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 457cb33..bff141e 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1490,9 +1490,17 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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: isConnected, + canStartStop: canStartOrStop, canManualPing: manualPing.allowed, isSessionActive: _autoPingEnabled, // Slot ownership must not follow the live manual-ping gate: cooldowns @@ -1509,8 +1517,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // 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: - manualPing.reason == 'Cooling down' ? null : manualPing.reason, + blockedReason: !_autoPingEnabled && !passiveStart.allowed + ? passiveStart.reason + : (manualPing.reason == 'Cooling down' ? null : manualPing.reason), ); } @@ -1523,6 +1532,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 @@ -1551,7 +1592,6 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); if (admission.refusal != null) return admission.refusal; if (!admission.shouldRun) return null; - if (!isConnected) return 'Not connected'; final requested = resolveWatchRequestedStartMode( requestedMode: command.mode, isConnected: isConnected, @@ -1563,6 +1603,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { WatchStartMode.hybrid => AutoMode.hybrid, null => _resolvedWatchSessionMode, }; + final availability = sessionStartAvailability(mode); + if (!availability.allowed) { + return availability.reason ?? 'Could not start'; + } unawaited(_runWatchStartSession(mode)); return null; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 2e9df65..d2d55ac 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -438,6 +438,58 @@ WatchRequestedStartModeResolution resolveWatchRequestedStartMode({ typedef WatchCommandAdmission = ({bool shouldRun, String? refusal}); +typedef SessionStartAvailability = ({bool allowed, String? reason}); + +/// One start-admission rule shared by the wrist snapshot and command handler. +/// +/// Passive monitoring does not transmit, so a manual TX, its receive window, +/// TX cooldown, offline TX policy, and zone TX policy must not block it. The +/// setup and transition guards still apply to every mode. Keeping that split +/// here prevents the offered button and the radio admission from drifting back +/// into separate policy copies. +SessionStartAvailability resolveSessionStartAvailability({ + required bool isTransmitMode, + required bool isConnected, + required bool antennaConfigured, + required bool powerConfigured, + required bool isPendingDisable, + required bool isTargetedRunning, + required bool isAutoStarting, + required bool cooldownActive, + required bool isPingSending, + required bool rxWindowActive, + required bool txBlockedByOffline, + required bool txNotAllowed, + required String? transmitValidationReason, +}) { + if (!isConnected) return (allowed: false, reason: 'Not connected'); + if (isPendingDisable) return (allowed: false, reason: 'Still stopping'); + if (isTargetedRunning) { + return (allowed: false, reason: 'Trace session active'); + } + if (isAutoStarting) return (allowed: false, reason: 'Already starting'); + if (!antennaConfigured) { + return (allowed: false, reason: 'Select antenna option'); + } + if (!powerConfigured) { + return (allowed: false, reason: 'Select power level'); + } + + if (!isTransmitMode) return (allowed: true, reason: null); + + if (txBlockedByOffline) return (allowed: false, reason: 'Offline Mode'); + if (txNotAllowed) return (allowed: false, reason: 'Passive Only'); + if (cooldownActive) return (allowed: false, reason: 'Cooling down'); + if (isPingSending) return (allowed: false, reason: 'Ping in progress'); + if (rxWindowActive) { + return (allowed: false, reason: 'Listening for ping response'); + } + if (transmitValidationReason != null) { + return (allowed: false, reason: transmitValidationReason); + } + return (allowed: true, reason: null); +} + /// Resolve the wrist's single Start/Stop control without racing the phone's /// asynchronous start transaction. A second Start is the same intent and can /// disappear harmlessly; Stop is the opposite intent, so claiming success diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index d0a67cd..c103260 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -265,6 +265,37 @@ void main() { }); group('command kinds', () { + SessionStartAvailability startAvailability({ + bool isTransmitMode = true, + bool isConnected = true, + bool antennaConfigured = true, + bool powerConfigured = true, + bool isPendingDisable = false, + bool isTargetedRunning = false, + bool isAutoStarting = false, + bool cooldownActive = false, + bool isPingSending = false, + bool rxWindowActive = false, + bool txBlockedByOffline = false, + bool txNotAllowed = false, + String? transmitValidationReason, + }) => + resolveSessionStartAvailability( + isTransmitMode: isTransmitMode, + isConnected: isConnected, + antennaConfigured: antennaConfigured, + powerConfigured: powerConfigured, + isPendingDisable: isPendingDisable, + isTargetedRunning: isTargetedRunning, + isAutoStarting: isAutoStarting, + cooldownActive: cooldownActive, + isPingSending: isPingSending, + rxWindowActive: rxWindowActive, + txBlockedByOffline: txBlockedByOffline, + txNotAllowed: txNotAllowed, + transmitValidationReason: transmitValidationReason, + ); + test('round-trip through the wire names Swift sends', () { for (final kind in WatchCommandKind.values) { expect(WatchCommandKind.fromWire(kind.name), kind); @@ -335,6 +366,45 @@ void main() { expect(result, (mode: null, refusal: null)); }); + + test('start admission names every shared blocked precondition', () { + final blocked = { + 'Not connected': startAvailability(isConnected: false), + 'Still stopping': startAvailability(isPendingDisable: true), + 'Trace session active': startAvailability(isTargetedRunning: true), + 'Already starting': startAvailability(isAutoStarting: true), + 'Select antenna option': startAvailability(antennaConfigured: false), + 'Select power level': startAvailability(powerConfigured: false), + 'Offline Mode': startAvailability(txBlockedByOffline: true), + 'Passive Only': startAvailability(txNotAllowed: true), + 'Cooling down': startAvailability(cooldownActive: true), + 'Ping in progress': startAvailability(isPingSending: true), + 'Listening for ping response': startAvailability(rxWindowActive: true), + 'Waiting for GPS lock': startAvailability( + transmitValidationReason: 'Waiting for GPS lock', + ), + }; + + for (final entry in blocked.entries) { + expect(entry.value.allowed, isFalse, reason: entry.key); + expect(entry.value.reason, entry.key); + } + }); + + test('Passive ignores blockers that only constrain a transmitting start', + () { + final passive = startAvailability( + isTransmitMode: false, + cooldownActive: true, + isPingSending: true, + rxWindowActive: true, + txBlockedByOffline: true, + txNotAllowed: true, + transmitValidationReason: 'Waiting for GPS lock', + ); + + expect(passive, (allowed: true, reason: null)); + }); }); group('bridge command handling', () { From df145cff00823a5145c62181c691971d669fb251 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 19:38:47 -0700 Subject: [PATCH 42/71] Stop shipping map geography to a watch that isn't showing a map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on a maximal payload — 60 pings, 20 repeaters, four heard rows, encoded exactly as the wire does: full 11,609 bytes suppressed 1,443 bytes (87.6% smaller) Geography was built unconditionally while the phone had no idea what the watch was displaying; it knew only reachability and activation. So every ping coordinate, repeater name and colour was sorted, encoded, transmitted, decoded and discarded whenever the wearer sat on the readout, on Controls or Settings, or — most of a walk — had the wrist down in Always-On, where the readout draws none of it. The wrist now reports whether its current surface needs geography, over the queued command path that already existed. The phone skips building the markers as well as encoding them, so the wasted sort of up to two thousand ping candidates down to sixty goes with them. Every uncertainty resolves toward sending geography, because the failure modes are not symmetric: extra bytes cost battery, an empty map costs the feature. Suppression is a lease the wrist renews rather than a latch it sets, so a watch that stops reporting returns to full payloads by itself. Launch assumes geography is needed until the surface says otherwise. Returning to the map requests a full snapshot immediately instead of waiting for the next scheduled push, and a suppressed payload arriving after the map became visible triggers a throttled replacement rather than leaving half-stale markers on screen. Suppression waits fifteen seconds so an ordinary glance does not enqueue a false-then-true pair, which would trade one kind of waste for another. Wire stays at version 2: both fields are additive, an old phone ignores the claim and keeps sending everything, an absent flag decodes as included. --- ios/MeshMapperWatch/ContentView.swift | 2 +- ios/MeshMapperWatch/MapPage.swift | 11 ++ ios/MeshMapperWatch/WatchSessionClient.swift | 96 +++++++++- ios/Shared/MeshMapperWatchPayload.swift | 16 +- lib/providers/app_state_provider.dart | 50 +++-- lib/services/watch/watch_bridge_service.dart | 50 ++++- lib/services/watch/watch_models.dart | 41 +++- .../watch/watch_wire_contract_test.dart | 175 +++++++++++++++++- 8 files changed, 401 insertions(+), 40 deletions(-) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index f524c70..f3454e9 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -34,7 +34,7 @@ struct ContentView: View { // without making any individual page create a second one. NavigationStack { TabView(selection: $selection) { - MapPage().tag(0) + MapPage(isSelected: selection == 0).tag(0) ControlsPage().tag(1) // The sheet placement is opened by tapping the map's status panel, which diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 4b2461f..cca80e9 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -13,6 +13,11 @@ import WatchKit /// 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 @@ -34,6 +39,8 @@ struct MapPage: View { settings.mainPageContent == .map && !isLuminanceReduced } + private var needsMapGeo: Bool { showsMap && isSelected } + @State private var camera: MapCameraPosition = .automatic /// Non-nil once we have driven the camera. Assignment is not proof that @@ -234,6 +241,10 @@ struct MapPage: View { client.debugForceRefusal(forced) } #endif + client.setMapGeoNeeded(needsMapGeo) + } + .onChange(of: needsMapGeo) { _, needed in + client.setMapGeoNeeded(needed) } .onChange(of: trailingToolbarControl) { _, _ in // Stable facts own the slot, but a session transition still changes diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index d066265..44b0a2f 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -48,6 +48,15 @@ final class WatchSessionClient: NSObject { /// 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 } @@ -60,6 +69,9 @@ final class WatchSessionClient: NSObject { static let staleAfter: TimeInterval = 90 private static let cueFreshFor: TimeInterval = 30 private static let cueClockTolerance: 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. /// @@ -79,7 +91,8 @@ final class WatchSessionClient: NSObject { if session.activationState == .activated { ingest(context: session.receivedApplicationContext) - send(.requestSnapshot, silent: true) + requestFullSnapshot() + if !mapGeoNeeded { scheduleMapGeoSuppression() } return } @@ -99,7 +112,12 @@ final class WatchSessionClient: NSObject { /// 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, silent: Bool = false) { + func send( + _ kind: WatchCommand.Kind, + mode: String? = nil, + mapGeoNeeded: Bool? = nil, + silent: Bool = false + ) { guard let session, session.activationState == .activated else { if !silent { setLastRefusal("Not connected to iPhone", from: kind) @@ -110,6 +128,7 @@ final class WatchSessionClient: NSObject { let command = WatchCommand( kind: kind, mode: mode, + mapGeoNeeded: mapGeoNeeded, id: UUID().uuidString, issuedAtMs: Date().timeIntervalSince1970 * 1000 ) @@ -130,6 +149,61 @@ final class WatchSessionClient: NSObject { 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() + } + } + + private func sendMapGeoPreference(_ needed: Bool, force: Bool = false) { + guard force || lastSentMapGeoNeeded != needed else { return } + guard let session, session.activationState == .activated else { return } + lastSentMapGeoNeeded = needed + send(.requestSnapshot, mapGeoNeeded: needed, 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. + sendMapGeoPreference(true, force: true) + } + private func beginPending(_ kind: WatchCommand.Kind) { pendingCommandTimeoutTask?.cancel() pendingCommand = kind @@ -232,6 +306,21 @@ final class WatchSessionClient: NSObject { // state dedupe means no snapshot follows. self.clearPendingCommand() + if self.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 = self.lastMapGeoRecoveryRequestAt + if lastRequest == nil || + arrival.timeIntervalSince(lastRequest ?? .distantPast) >= + Self.mapGeoRecoveryThrottle + { + self.lastMapGeoRecoveryRequestAt = arrival + self.sendMapGeoPreference(true, force: true) + } + } + if let cue = decoded.cue, Self.isFresh(cue, at: arrival), self.presentedCueIDs.insert(cue.id).inserted @@ -266,7 +355,8 @@ extension WatchSessionClient: WCSessionDelegate { ingest(context: session.receivedApplicationContext) if pendingRefresh { pendingRefresh = false - send(.requestSnapshot, silent: true) + requestFullSnapshot() + if !mapGeoNeeded { scheduleMapGeoSuppression() } } } } diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index 02633f5..a61f440 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -232,6 +232,9 @@ struct WatchSnapshot: Codable, Hashable { /// 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? @@ -255,6 +258,7 @@ struct WatchSnapshot: Codable, Hashable { queueSize: Int, pingColor: WatchColor?, availableStartModes: [String] = ["passive"], + mapGeoIncluded: Bool = true, geo: WatchGeo, controls: WatchControls, cue: WatchHapticCue?, @@ -277,6 +281,7 @@ struct WatchSnapshot: Codable, Hashable { self.queueSize = queueSize self.pingColor = pingColor self.availableStartModes = availableStartModes + self.mapGeoIncluded = mapGeoIncluded self.geo = geo self.controls = controls self.cue = cue @@ -287,7 +292,7 @@ struct WatchSnapshot: Codable, Hashable { case wireVersion, sessionId, mode, phase, phaseTitle, phaseDetail case phaseEndsAtMs, phaseDurationMs, isConnected, zoneCode case txCount, rxCount, discoveryCount, traceCount, queueSize, pingColor - case availableStartModes, geo, controls, cue, updatedAtMs + case availableStartModes, mapGeoIncluded, geo, controls, cue, updatedAtMs } init(from decoder: Decoder) throws { @@ -315,6 +320,12 @@ struct WatchSnapshot: Codable, Hashable { [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) @@ -361,6 +372,9 @@ struct WatchCommand: Codable, Hashable { /// 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? /// Client-generated, so the phone can dedupe redelivered commands. let id: String /// Queued delivery can outlive the place where a transmit was requested. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index bff141e..65c3389 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1240,6 +1240,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { isConnected: isConnected, controls: controls, cue: _watchCue, + mapGeoIncluded: _watchBridge.shouldIncludeMapGeo, ); } @@ -1256,6 +1257,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final now = DateTime.now(); final phaseDurationMs = _phaseDurationMsFor(phase.endsAt); final pingColor = _resolveWatchPingColor(); + final includeMapGeo = _watchBridge.shouldIncludeMapGeo; final core = LiveActivitySnapshot( sessionId: _liveActivitySessionId ?? 'idle', @@ -1284,8 +1286,9 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return WatchSnapshot( core: core, - geo: _buildWatchGeo(), + geo: _buildWatchGeo(includeMapGeo: includeMapGeo), controls: _buildWatchControls(), + mapGeoIncluded: includeMapGeo, availableStartModes: _availableWatchStartModes, pingColor: pingColor, cue: _watchCue, @@ -1316,20 +1319,14 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { return null; } - WatchGeo _buildWatchGeo() { + WatchGeo _buildWatchGeo({required bool includeMapGeo}) { final position = _resolveWatchPosition(); - // Repeaters heard during the current cycle get the highlight ring. - final heardIds = _topRepeatersOverlay - .map((r) => r.repeaterId.toUpperCase()) - .toSet(); - // 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; - if (rxSlot != null) heardIds.add(rxSlot.repeaterId.toUpperCase()); // Overlay IDs are hex path hashes, so resolve names by prefix at whatever // length this zone actually uses. @@ -1339,6 +1336,33 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final repeaterByHex = hexLength > 0 ? WatchGeoBuilder.indexByHexPrefix(_repeaters, hexLength) : const {}; + final heard = WatchGeoBuilder.buildHeard( + top: top, + rxSlot: rxSlot, + repeaterByHex: repeaterByHex, + topAt: _topRepeatersOverlayUpdatedAt, + rxAt: _liveActivityRxUpdatedAt, + lat: position?.lat, + lon: position?.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, @@ -1354,15 +1378,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { lat: position?.lat, lon: position?.lon, ), - heard: WatchGeoBuilder.buildHeard( - top: top, - rxSlot: rxSlot, - repeaterByHex: repeaterByHex, - topAt: _topRepeatersOverlayUpdatedAt, - rxAt: _liveActivityRxUpdatedAt, - lat: position?.lat, - lon: position?.lon, - ), + heard: heard, linkedRepeaterIds: [ ...WatchGeoBuilder.resolveUniqueHexPrefixes( repeaters: _repeaters, diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index ee6211f..9083395 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -35,6 +35,8 @@ class WatchBridgeService { static const Duration _debounceDelay = Duration(milliseconds: 200); static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); static const Duration _maximumCommandAge = Duration(seconds: 30); + static const Duration _mapGeoClaimFreshFor = Duration(minutes: 10); + static const Duration _clockTolerance = Duration(seconds: 5); final MethodChannel _channel; @@ -53,6 +55,8 @@ class WatchBridgeService { bool _disposed = false; bool _didReconcileNativeState = false; bool _canSync = false; + DateTime? _mapGeoSuppressedAt; + double? _lastMapGeoClaimIssuedAtMs; Future _operationChain = Future.value(); /// Commands already handled, so redelivery can't fire a second transmit. @@ -62,6 +66,17 @@ class WatchBridgeService { !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; bool get canSync => isSupportedPlatform && _canSync; + /// Whether the next payload must carry map-only geography. + /// + /// Suppression is leased rather than latched. If the wrist stops renewing + /// its claim, the phone returns to full geo on the next build; excess bytes + /// are safer than leaving a newly-visible map blank. + bool get shouldIncludeMapGeo { + final suppressedAt = _mapGeoSuppressedAt; + if (suppressedAt == null) return true; + return DateTime.now().difference(suppressedAt) >= _mapGeoClaimFreshFor; + } + /// Wire up the inbound command path. Safe to call more than once. void attachCommandHandler( WatchCommandHandler handler, { @@ -113,9 +128,35 @@ class WatchBridgeService { final rawIssuedAtMs = args['issuedAtMs']; final issuedAtMs = rawIssuedAtMs is num ? rawIssuedAtMs.toDouble() : null; + final ageMs = issuedAtMs == null + ? null + : DateTime.now().millisecondsSinceEpoch - issuedAtMs; + final requestedMapGeo = args['mapGeoNeeded']; + final mapGeoNeeded = requestedMapGeo is bool ? requestedMapGeo : null; + final freshMapGeoSuppression = ageMs != null && + ageMs >= -_clockTolerance.inMilliseconds && + ageMs <= _maximumCommandAge.inMilliseconds; + final latestMapGeoClaim = _lastMapGeoClaimIssuedAtMs; + final suppressionIsNewest = issuedAtMs != null && + (latestMapGeoClaim == null || issuedAtMs >= latestMapGeoClaim); + final effectiveMapGeoNeeded = mapGeoNeeded == false && + (!freshMapGeoSuppression || !suppressionIsNewest) + ? null + : mapGeoNeeded; + if (kind == WatchCommandKind.requestSnapshot && + effectiveMapGeoNeeded != null) { + // A stale or out-of-order false could arrive after the wrist returned to + // the map. Ignore it silently; true is always safe because it only + // restores detail. Never move the ordering watermark backwards when an + // older true is accepted for that conservative reason. + _mapGeoSuppressedAt = effectiveMapGeoNeeded ? null : DateTime.now(); + if (issuedAtMs != null && + (latestMapGeoClaim == null || issuedAtMs >= latestMapGeoClaim)) { + _lastMapGeoClaimIssuedAtMs = issuedAtMs; + } + } if (kind != WatchCommandKind.requestSnapshot && issuedAtMs != null) { - final ageMs = DateTime.now().millisecondsSinceEpoch - issuedAtMs; - if (ageMs > _maximumCommandAge.inMilliseconds) { + if (ageMs! > _maximumCommandAge.inMilliseconds) { const reason = 'Took too long to reach iPhone'; // This window is about correctness, not queue housekeeping: executing // a transmit after the vehicle has moved attributes it to the wrong @@ -133,6 +174,7 @@ class WatchBridgeService { final admission = handler(WatchCommand( kind: kind, mode: args['mode'] as String?, + mapGeoNeeded: effectiveMapGeoNeeded, )); final refusal = admission is Future ? await admission : admission; @@ -178,6 +220,8 @@ class WatchBridgeService { // availability remains true, or an installed replacement watch could wait // forever for state whose fingerprint Dart still considers delivered. if (!available || refreshNativeState) { + _mapGeoSuppressedAt = null; + _lastMapGeoClaimIssuedAtMs = null; _lastPayload = null; _lastUrgencyKey = null; _lastSentAt = null; @@ -331,5 +375,7 @@ class WatchBridgeService { _commandRefusalHandler = null; _availabilityHandler = null; _snapshotDeliveryHandler = null; + _mapGeoSuppressedAt = null; + _lastMapGeoClaimIssuedAtMs = null; } } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index d2d55ac..f349d75 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -25,10 +25,10 @@ class WatchWire { /// gone: the overlay is fed `directRepeaters` only. /// /// Additive optional fields do not bump this version. A new watch defaults - /// an absent start-mode list to Passive and absent Ping applicability to - /// false, while an older phone ignores the optional mode on a command and - /// retains its existing safe resolver. A bump would therefore strand - /// compatible pairs without preventing a bad decode. + /// an absent start-mode list to Passive, absent Ping applicability to false, + /// and absent map-geo state to included. An older phone ignores optional + /// command fields and keeps sending full geography. A bump would therefore + /// strand compatible pairs without preventing a bad decode. static const int version = 2; static const int maxPings = 60; @@ -208,12 +208,15 @@ class WatchGeo { final List heard; final List linkedRepeaterIds; - Map toMap() => { + Map toMap({bool includeMapDetail = true}) => { 'you': you?.toMap(), - 'pings': pings.map((p) => p.toMap()).toList(), - 'repeaters': repeaters.map((r) => r.toMap()).toList(), + 'pings': + includeMapDetail ? pings.map((p) => p.toMap()).toList() : const [], + 'repeaters': includeMapDetail + ? repeaters.map((r) => r.toMap()).toList() + : const [], 'heard': heard.map((h) => h.toMap()).toList(), - 'linkedRepeaterIds': linkedRepeaterIds, + 'linkedRepeaterIds': includeMapDetail ? linkedRepeaterIds : const [], }; } @@ -294,6 +297,7 @@ class WatchSnapshot { required this.geo, required this.controls, required this.updatedAt, + this.mapGeoIncluded = true, this.availableStartModes = const [WatchStartMode.passive], this.pingColor, this.cue, @@ -304,6 +308,11 @@ class WatchSnapshot { final LiveActivitySnapshot core; final WatchGeo geo; final WatchControls controls; + + /// False means map-only arrays were deliberately cleared, not that the + /// current area simply has no markers. The wrist uses this to recover from + /// an out-of-order suppressed context when the map is already visible. + final bool mapGeoIncluded; final List availableStartModes; final WatchColor? pingColor; final WatchHapticCue? cue; @@ -333,9 +342,13 @@ class WatchSnapshot { 'traceCount': core.traceCount, 'queueSize': core.queueSize, 'pingColor': pingColor?.toMap(), + 'mapGeoIncluded': mapGeoIncluded, 'availableStartModes': availableStartModes.map((mode) => mode.name).toList(), - 'geo': geo.toMap(), + // Keep the geo object and its keys for older v2 watches, but clear the + // map-only arrays when the wrist has leased suppression. The provider + // also avoids constructing them; this is the last-line wire invariant. + 'geo': geo.toMap(includeMapDetail: mapGeoIncluded), 'controls': controls.toMap(), 'cue': cue?.toMap(), 'updatedAtMs': updatedAt.millisecondsSinceEpoch.toDouble(), @@ -355,6 +368,7 @@ class WatchSnapshot { isConnected: core.isConnected, controls: controls, cue: cue, + mapGeoIncluded: mapGeoIncluded, ); static String buildUrgencyKey({ @@ -367,6 +381,7 @@ class WatchSnapshot { required bool isConnected, required WatchControls controls, required WatchHapticCue? cue, + bool mapGeoIncluded = true, }) => [ sessionId, @@ -380,6 +395,7 @@ class WatchSnapshot { controls.canManualPing, controls.isSessionActive, controls.manualPingApplicable, + mapGeoIncluded, cue?.id ?? '', ].join('|'); } @@ -402,10 +418,15 @@ enum WatchCommandKind { /// Decoded wrist intent. [mode] stays raw until phone-side admission so an /// unknown value can be refused rather than mistaken for an omitted mode. class WatchCommand { - const WatchCommand({required this.kind, this.mode}); + const WatchCommand({required this.kind, this.mode, this.mapGeoNeeded}); final WatchCommandKind kind; final String? mode; + + /// Optional state piggybacked on requestSnapshot. Old phones ignore it and + /// keep the fail-safe full payload; new phones suppress only after a fresh + /// false claim. + final bool? mapGeoNeeded; } typedef WatchRequestedStartModeResolution = ({ diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index c103260..75831f4 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -18,6 +18,8 @@ WatchSnapshot _snapshot({ String phaseTitle = 'Listening', bool isConnected = true, int? phaseDurationMs, + WatchGeo? geo, + bool mapGeoIncluded = true, List availableStartModes = const [WatchStartMode.passive], }) => WatchSnapshot( @@ -40,17 +42,19 @@ WatchSnapshot _snapshot({ repeatersAreCurrent: true, updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), ), - geo: const WatchGeo( - pings: [], - repeaters: [], - heard: [], - linkedRepeaterIds: [], - ), + geo: geo ?? + const WatchGeo( + pings: [], + repeaters: [], + heard: [], + linkedRepeaterIds: [], + ), controls: const WatchControls( canStartStop: true, canManualPing: false, isSessionActive: true, ), + mapGeoIncluded: mapGeoIncluded, availableStartModes: availableStartModes, pingColor: const WatchColor(1, 0, 0), phaseDurationMs: phaseDurationMs, @@ -82,6 +86,7 @@ void main() { 'traceCount', 'queueSize', 'pingColor', + 'mapGeoIncluded', 'availableStartModes', 'geo', 'controls', @@ -112,6 +117,103 @@ void main() { expect((map['pingColor']! as Map).keys.toSet(), {'r', 'g', 'b'}); }); + test('full snapshots retain every geography field', () { + final at = DateTime.fromMillisecondsSinceEpoch(1759999980000); + final geo = WatchGeo( + you: WatchPosition(lat: 47.6, lon: -122.3, fixedAt: at), + pings: [ + WatchPing( + id: 'ping-1', + lat: 47.61, + lon: -122.31, + kind: 'tx', + color: const WatchColor(0, 1, 0), + at: at, + ), + ], + repeaters: const [ + WatchRepeater( + id: 'database-1', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.62, + lon: -122.32, + color: WatchColor(1, 0, 1), + heardThisCycle: true, + ), + ], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: at, + ), + ], + linkedRepeaterIds: const ['database-1'], + ); + final map = _snapshot(geo: geo).toMap(); + final encodedGeo = map['geo']! as Map; + + expect(map['mapGeoIncluded'], isTrue); + expect(encodedGeo['you'], isNotNull); + expect(encodedGeo['pings'], hasLength(1)); + expect(encodedGeo['repeaters'], hasLength(1)); + expect(encodedGeo['heard'], hasLength(1)); + expect(encodedGeo['linkedRepeaterIds'], ['database-1']); + }); + + test('suppressed snapshots clear only map detail', () { + final at = DateTime.fromMillisecondsSinceEpoch(1759999980000); + final geo = WatchGeo( + you: WatchPosition(lat: 47.6, lon: -122.3, fixedAt: at), + pings: [ + WatchPing( + id: 'ping-1', + lat: 47.61, + lon: -122.31, + kind: 'tx', + color: const WatchColor(0, 1, 0), + at: at, + ), + ], + repeaters: const [ + WatchRepeater( + id: 'database-1', + hexId: '4E5D82', + name: 'Capitol Hill', + lat: 47.62, + lon: -122.32, + color: WatchColor(1, 0, 1), + heardThisCycle: true, + ), + ], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: at, + ), + ], + linkedRepeaterIds: const ['database-1'], + ); + final map = _snapshot(geo: geo, mapGeoIncluded: false).toMap(); + final encodedGeo = map['geo']! as Map; + + expect(map['mapGeoIncluded'], isFalse); + expect(encodedGeo['you'], isNotNull, + reason: 'the fix is cheap and remains useful state'); + expect(encodedGeo['heard'], hasLength(1), + reason: 'the readout consumes Top Heard'); + expect(encodedGeo['pings'], isEmpty); + expect(encodedGeo['repeaters'], isEmpty); + expect(encodedGeo['linkedRepeaterIds'], isEmpty); + }); + + test('old payload semantics default map geography to included', () { + expect(_snapshot().mapGeoIncluded, isTrue); + expect(_snapshot().toMap()['mapGeoIncluded'], isTrue); + }); + test('timestamps are epoch milliseconds as doubles', () { final map = _snapshot().toMap(); expect(map['updatedAtMs'], isA()); @@ -452,6 +554,8 @@ void main() { String id, String kind, { String? mode, + bool? mapGeoNeeded, + double? issuedAtMs, }) async { final result = await TestDefaultBinaryMessengerBinding .instance.defaultBinaryMessenger @@ -462,6 +566,8 @@ void main() { 'id': id, 'kind': kind, if (mode != null) 'mode': mode, + if (mapGeoNeeded != null) 'mapGeoNeeded': mapGeoNeeded, + if (issuedAtMs != null) 'issuedAtMs': issuedAtMs, }), ), null, @@ -625,6 +731,63 @@ void main() { expect(reply?['accepted'], isTrue); }); + test('fresh map demand suppresses and restores geography', () async { + bridge.attachCommandHandler((_) => null); + final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + + await sendCommand( + 'geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: nowMs, + ); + expect(bridge.shouldIncludeMapGeo, isFalse); + + await sendCommand( + 'geo-on', + 'requestSnapshot', + mapGeoNeeded: true, + ); + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + + test('stale suppression cannot blank a newly visible map', () async { + bridge.attachCommandHandler((_) => null); + final staleMs = DateTime.now() + .subtract(const Duration(seconds: 45)) + .millisecondsSinceEpoch + .toDouble(); + + await sendCommand( + 'stale-geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: staleMs, + ); + + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + + test('an older queued suppression cannot overtake map demand', () async { + bridge.attachCommandHandler((_) => null); + final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble(); + + await sendCommand( + 'new-geo-on', + 'requestSnapshot', + mapGeoNeeded: true, + issuedAtMs: nowMs, + ); + await sendCommand( + 'old-geo-off', + 'requestSnapshot', + mapGeoNeeded: false, + issuedAtMs: nowMs - 1000, + ); + + expect(bridge.shouldIncludeMapGeo, isTrue); + }); + test('a redelivered command does not transmit twice', () async { bridge.attachCommandHandler((command) async { handled.add(command.kind); From c2b97355341ab905f7c2ca14ed0743ec0bed24a2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:08:33 -0700 Subject: [PATCH 43/71] Let MapKit know what covers the map instead of shifting the camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Crown zooms about the map's region centre. We had been lifting that centre by hand so the fix rendered above the status panel, so a zoom slid the puck across the screen and the correction pass snapped it back when the gesture ended. Both halves worked as designed; the design was the problem. Telling MapKit what is covering it — a safe-area inset for the panel and for the toolbar strip — makes its own centre the centre of the band the wearer can actually see. The fix then lands there because it is centred on, not translated toward, and a zoom anchors on it. Adam drove the Crown on a build of this and reported it stays centred throughout, which is the half neither the simulator nor a static capture can show. That removes the machinery the old approach needed: the target point, the coordinate translation, the post-render correction loop and its deadband, and the remembered rendered centre. `programmaticCenter` stays, now solely as the sentinel for the span handshake, which is unaffected — it is only ever tested for non-nil. Both edges are inset on purpose. Insetting only the bottom centres the fix in the band from the top of the display to the panel, which still includes the toolbar, and the puck reads high — plainly so on 46 mm where the chrome is a smaller fraction of the screen. Measured: the fix sits at 91.2 pt on 40 mm against a predicted 92, and 116.8 pt on 46 mm against 117. The status panel is pixel-identical across every variant, a fresh install still settles at 250 m on both sizes, and a panel height change — forced with long hex IDs — leaves the persisted span untouched, so a growing panel cannot drift the zoom. `MapReader` is now vestigial: nothing calls `proxy.convert`, so the proxy is threaded through five functions unused and its comment is no longer true. Removing it is a hierarchy change around a map whose launch and framing behaviour was just verified, so it belongs in its own change with its own A/B rather than riding along here. --- ios/MeshMapperWatch/MapPage.swift | 92 ++++++++++--------------------- 1 file changed, 30 insertions(+), 62 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index cca80e9..5650dd5 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -106,11 +106,17 @@ struct MapPage: View { } } - /// Where the fix belongs on screen: midway between the top of the display and - /// the top of the panel. - private var targetPoint: CGPoint? { - guard panelFrame.height > 0 else { return nil } - return CGPoint(x: panelFrame.midX, y: panelFrame.minY / 2) + /// How much of the display the status panel is covering, handed to MapKit so + /// it frames the camera in the band that remains visible. + /// + /// Measured rather than assumed: the panel's height changes with its content + /// — one column or two, with or without heard rows — and a stale constant + /// would drift the fix off centre exactly when the panel grew. Before the + /// first measurement this is zero, which simply centres on the full display + /// for a frame. + private var panelCameraInset: CGFloat { + guard panelFrame.height > 0 else { return 0 } + return max(0, WKInterfaceDevice.current().screenBounds.height - panelFrame.minY) } /// The placement scales with the estimated corner radius, putting every @@ -880,9 +886,19 @@ struct MapPage: View { 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 and a Crown zoom anchors on it instead of sliding it across. + // + // 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 noteRenderedRegion(context.region) - correctPlacement(proxy) } // The shell's navigation host supplies the system toolbar placement but // must not buy it by shortening the basemap. Only MapKit extends under that @@ -975,9 +991,11 @@ struct MapPage: View { private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { guard showsMap, force || isFollowing, let fix else { return } - let center = centerPlacing(fix, proxy: proxy) - programmaticCenter = center - let region = MKCoordinateRegion(center: center, span: currentSpan) + // 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. + programmaticCenter = fix + let region = MKCoordinateRegion(center: fix, span: currentSpan) if force { // A tap is a rare, explicit request to move the map, so animation shows @@ -988,56 +1006,12 @@ struct MapPage: View { camera = .region(region) } } else { - // First placement, GPS steps and placement corrections all cut so the - // puck stays visually fixed while the world moves beneath it. + // First placement and GPS steps cut, so the puck stays visually fixed + // while the world moves beneath it. camera = .region(region) } } - /// Region centre that puts [fix] midway between the top of the display and - /// the top of the panel. - /// - /// MapKit centres the region in the map, so with a panel over the lower third - /// the fix would sit low and partly behind it. Rather than predict how far to - /// shift, this asks the map which coordinate is at the target point today and - /// translates the camera by the difference — exact whatever the projection, - /// the zoom, or the latitude. - private func centerPlacing( - _ fix: CLLocationCoordinate2D, - proxy: MapProxy - ) -> CLLocationCoordinate2D { - // Before the first render there is nothing to translate against; centring - // on the fix is the right opening move, and `correctPlacement` lifts it as - // soon as the map reports back. - guard let targetPoint, - let renderedCenter, - let atTarget = proxy.convert(targetPoint, from: .global) - else { return fix } - - // Clamped so a bad conversion — an off-map point, a mid-animation read — - // can never fling the camera somewhere the wearer has to chase. - let lift = (fix.latitude - atTarget.latitude) - .clamped(to: -currentSpan.latitudeDelta...currentSpan.latitudeDelta) - return CLLocationCoordinate2D( - latitude: renderedCenter.latitude + lift, - longitude: fix.longitude - ) - } - - /// Nudge the camera once the map reports where things really landed. - /// - /// The first placement runs before any render, and a zoom changes the scale - /// underneath us, so placement is a feedback loop rather than a calculation. - /// The deadband is what stops it: each pass lands within a couple of points, - /// the next sees no error worth fixing, and it settles. - private func correctPlacement(_ proxy: MapProxy) { - guard showsMap, isFollowing, let fix, let targetPoint, - let point = proxy.convert(fix, to: .global) - else { return } - guard abs(point.y - targetPoint.y) > 6 else { return } - recenterIfFollowing(proxy) - } - /// 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 @@ -1049,15 +1023,9 @@ struct MapPage: View { ) } - /// Centre of the region MapKit last rendered — the fixed point every - /// placement is measured against. - @State private var renderedCenter: CLLocationCoordinate2D? - /// Track the region MapKit actually rendered, so a Digital Crown zoom is not - /// thrown away on the next follow update and so placement has a known - /// starting point. + /// thrown away on the next follow update. private func noteRenderedRegion(_ region: MKCoordinateRegion) { - renderedCenter = region.center // `.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 From 13346e9c0c49e8d9ae9c4a0f0a3dc6ad0ffd6978 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:15:54 -0700 Subject: [PATCH 44/71] Flag the vestigial MapReader for a decision before the PR Camera placement moved to safe-area insets, so nothing calls proxy.convert any more and the reader's comment claimed a purpose it no longer has. Record what it is and why removing it wants its own change, rather than leaving a false rationale for the next reader to trust. --- ios/MeshMapperWatch/MapPage.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 5650dd5..43f4add 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -269,9 +269,15 @@ struct MapPage: View { private var pageContent: some View { ZStack { if showsMap { - // The proxy is the only reliable way to relate a coordinate to a point - // on screen. Keep the reader inside this branch: constructing even map + // Keep the reader inside this branch: constructing even map // infrastructure behind the readout would defeat its battery purpose. + // + // DECIDE BEFORE OPENING A PR: this reader is now vestigial. Camera + // placement moved to safe-area insets, so nothing calls + // `proxy.convert` and the proxy is threaded through five functions + // unused. Removing it is a hierarchy change around a map whose launch + // and framing behaviour was verified by measurement, so it wants its + // own change and its own A/B — not a quiet tidy-up inside another one. MapReader { proxy in mapContent(proxy) } From 336d047485c45d0e2a792171e0b88f7fcd732c35 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:15:54 -0700 Subject: [PATCH 45/71] Stop renaming every map marker when one new ping arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marker ids embedded the entry's index in its source list. Those histories insert at the front and trim from the back, so one new discovery renumbered every surviving marker and SwiftUI saw sixty replacements rather than a single insertion — tearing down and rebuilding the whole annotation set to show one new dot. TX and RX had the same fault once their five-hundred-entry lists began trimming. Identity now comes from the event's own timestamp, which is intrinsic to it and survives both insertion and trimming. Two events of one kind inside the same millisecond are the only case needing a discriminator, and it counts within the colliding group rather than across the list, so it does not reintroduce positional churn. The ids are also marginally shorter than the ones they replace. The regression test was checked against the old implementation and fails on it, so it covers the property rather than merely describing it. Not measured: how much energy the churn actually cost. That needs Watch Instruments on hardware. The churn itself was certain from the code, and the fix is cheap enough not to need the number first. --- lib/services/watch/watch_geo_builder.dart | 35 ++++++++++++------ .../watch/watch_geo_builder_test.dart | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index 4c80151..d9118cf 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -131,10 +131,24 @@ class WatchGeoBuilder { }) { final pings = []; - for (var i = 0; i < txPings.length; i++) { - final tx = txPings[i]; + // Identity must not depend on a marker's position in its source list. + // These histories insert at the front and trim from the back, so a + // positional id renames every surviving marker whenever one arrives, and + // SwiftUI then tears down and rebuilds all sixty annotations to show one + // new dot. Timestamps are intrinsic to the event and survive both. + final seen = {}; + String stableId(String kind, DateTime at) { + final base = '$kind-${at.millisecondsSinceEpoch}'; + final n = seen.update(base, (v) => v + 1, ifAbsent: () => 0); + // Two events of one kind inside the same millisecond are the only case + // needing a discriminator, and it stays put because it counts within the + // colliding group rather than across the whole list. + return n == 0 ? base : '$base~$n'; + } + + for (final tx in txPings) { pings.add(WatchPing( - id: 'tx-${tx.timestamp.millisecondsSinceEpoch}-$i', + id: stableId('tx', tx.timestamp), lat: tx.latitude, lon: tx.longitude, kind: 'tx', @@ -143,10 +157,9 @@ class WatchGeoBuilder { )); } - for (var i = 0; i < rxPings.length; i++) { - final rx = rxPings[i]; + for (final rx in rxPings) { pings.add(WatchPing( - id: 'rx-${rx.timestamp.millisecondsSinceEpoch}-$i', + id: stableId('rx', rx.timestamp), lat: rx.latitude, lon: rx.longitude, kind: 'rx', @@ -155,10 +168,9 @@ class WatchGeoBuilder { )); } - for (var i = 0; i < discLogEntries.length; i++) { - final entry = discLogEntries[i]; + for (final entry in discLogEntries) { pings.add(WatchPing( - id: 'disc-${entry.timestamp.millisecondsSinceEpoch}-$i', + id: stableId('disc', entry.timestamp), lat: entry.latitude, lon: entry.longitude, kind: 'disc', @@ -167,10 +179,9 @@ class WatchGeoBuilder { )); } - for (var i = 0; i < traceLogEntries.length; i++) { - final entry = traceLogEntries[i]; + for (final entry in traceLogEntries) { pings.add(WatchPing( - id: 'trace-${entry.timestamp.millisecondsSinceEpoch}-$i', + id: stableId('trace', entry.timestamp), lat: entry.latitude, lon: entry.longitude, kind: 'trace', diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index b2c8a3c..dcc4543 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -76,6 +76,43 @@ void main() { setUp(() => PingColors.setColorVisionType(ColorVisionType.none)); group('buildPings', () { + test('marker identity survives a new event arriving at the front', () { + // These histories insert newest-first. A positional id renumbered every + // surviving marker whenever one arrived, so SwiftUI rebuilt all sixty + // annotations to show one new dot. + final base = DateTime(2026, 8, 12, 10); + final older = List.generate(5, (i) => _disc(base.add(Duration(minutes: i)), discovered: true)); + + List idsFor(List entries) => WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: entries, + traceLogEntries: const [], + ).map((p) => p.id).toList(); + + final before = idsFor(older.reversed.toList()); + final arrival = _disc(base.add(const Duration(minutes: 9)), discovered: true); + final after = idsFor([arrival, ...older.reversed]); + + expect(after.length, before.length + 1); + expect( + after.toSet().containsAll(before), + isTrue, + reason: 'every pre-existing marker must keep its id', + ); + }); + + test('two events of one kind in the same millisecond stay distinct', () { + final at = DateTime(2026, 8, 12, 10); + final pings = WatchGeoBuilder.buildPings( + txPings: const [], + rxPings: const [], + discLogEntries: [_disc(at, discovered: true), _disc(at, discovered: false)], + traceLogEntries: const [], + ); + expect(pings.map((p) => p.id).toSet().length, 2); + }); + test('merges TX and RX newest-first and caps the list', () { final base = DateTime(2026, 8, 12, 10); final tx = List.generate(40, (i) => _tx(base.add(Duration(minutes: i)))); From df0dcbdbdbaee12f217bbefb05b2f6a40ce62b93 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:28:04 -0700 Subject: [PATCH 46/71] Add a wrist switch for measuring the countdown drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar's fill is an animated layout width, so SwiftUI re-runs layout on the main thread every frame for the whole phase instead of handing a transform to the render server. That is the expensive class of animation, and a session is almost entirely consecutive countdown phases — but how much energy it actually costs is not knowable from the source, and the cheaper alternatives change how the bar looks. Adam chose this bar, so the measurement has to come before the redesign. A DEBUG-only Settings toggle freezes the drain and leaves everything else running: same snapshots, same markers, same live timer text, same layout. An Instruments trace of the two states therefore isolates this one animation rather than a whole different build. It lives on the watch rather than behind a launch argument so the two states can be compared on a walk without a Mac, and it is read statically so flipping it does not invalidate the view mid-drain and perturb the trace it exists to produce. Compiled out of Release. --- ios/MeshMapperWatch/MapPage.swift | 11 +++++++++++ ios/MeshMapperWatch/SettingsPage.swift | 8 ++++++++ ios/MeshMapperWatch/WatchSettings.swift | 17 +++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 43f4add..39f98bd 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -1158,9 +1158,20 @@ private struct WatchPhaseBar: View { guard remaining > 0 else { return } await Task.yield() + #if DEBUG + // A/B harness for the drain's energy cost. The fill is an animated *layout* + // width, so SwiftUI re-runs layout every frame for the whole phase rather + // than handing a transform to the render server. Freezing it leaves every + // other cost in place — same snapshots, same markers, same timer text — so + // an Instruments trace of the two states isolates this animation alone. + if !WatchSettings.debugFreezesTimerBar { + withAnimation(.linear(duration: remaining)) { remainingFraction = 0 } + } + #else withAnimation(.linear(duration: remaining)) { remainingFraction = 0 } + #endif do { try await Task.sleep(for: .seconds(remaining)) diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 75972f2..571b8a1 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -66,6 +66,14 @@ struct SettingsPage: View { isOn: $settings.showPingWhenAvailable ) } + + #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) + } + #endif } .font(.caption) } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 28cb3d4..17f78ee 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -164,6 +164,23 @@ final class WatchSettings { didSet { defaults.set(showPingWhenAvailable, forKey: Key.showPingWhenAvailable) } } + #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") } + } + #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 From a1dbb2f4d3abd23c293e7de55ab26a25931c048d Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 20:53:55 -0700 Subject: [PATCH 47/71] Stop a swipe off the map stranding a sheet over the next page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swiping up from the map left the controls page blurred and deaf to every swipe and Crown turn. It reads as a crash and is not one: the process lives, holding a modal it should never have raised. Two faults compound. The status panel is a button that opens the heard sheet, and it lies across the bottom of the map — exactly where an upward page swipe begins — so the pager and the button both claimed the same touch. Then the page changed underneath the sheet, leaving a modal belonging to the map presented over the controls page, blurring it and swallowing input. That became reachable when the map gave up `.pan`. Before, an upward drag moved the basemap; now it pages, so wearers swipe from wherever their thumb rests, and the panel is the largest target on the screen. The panel now opens the sheet only when the heard list is actually placed in a sheet. With the list on its own page — the default — it was presenting a duplicate of a page that already existed, so the tap had nothing to offer and every cost of firing by accident. And a sheet is dismissed when its page stops being selected, because a sheet belongs to the page that raised it; that guard holds however the sheet was raised. Reproduced first: presenting the sheet and then changing pages leaves it stranded with the controls page's buttons bleeding through behind it. Both are fixed, and the sheet still presents correctly over the map when that is the chosen placement. The page-switch launch argument used to reproduce it stays, documented with the others. The simulator cannot swipe, and this class of defect is invisible without it. --- ios/MeshMapperWatch/ContentView.swift | 14 ++++++++++++++ ios/MeshMapperWatch/MapPage.swift | 13 +++++++++++++ ios/MeshMapperWatch/SampleSnapshot.swift | 4 ++++ 3 files changed, 31 insertions(+) diff --git a/ios/MeshMapperWatch/ContentView.swift b/ios/MeshMapperWatch/ContentView.swift index f3454e9..f8b67a9 100644 --- a/ios/MeshMapperWatch/ContentView.swift +++ b/ios/MeshMapperWatch/ContentView.swift @@ -56,6 +56,20 @@ struct ContentView: View { 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/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 39f98bd..a2a5e33 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -252,6 +252,13 @@ struct MapPage: View { .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 } + } .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. @@ -719,6 +726,12 @@ struct MapPage: View { /// 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) { diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index a998edb..1ac7991 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -20,6 +20,10 @@ import Foundation /// - `-MeshMapperForceDimmed YES` renders the reduced-luminance readout. /// - `-MeshMapperForceRefusal ` presents the failure banner; capture /// within six seconds because it deliberately uses the production expiry. +/// - `-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. /// /// Listening and active remain the defaults so existing capture commands keep /// their behaviour. From 8dd93e27c4e77ba30858a54126cad539c99b04e5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 13 Aug 2026 21:06:36 -0700 Subject: [PATCH 48/71] Measure the panel's height, not where it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swiping off the map wedged the watch: the destination page rendered blurred and stopped accepting swipes and Crown input. No crash report, process alive — the main thread simply never came free. A log capture during the gesture showed why. In forty-five seconds: 444,859 lines, 21,611 MapKit reconfigurations, and 194,490 evaluations of one debug flag read from the view body. Roughly 480 map rebuilds and 4,300 body passes per second. The cycle ran through the camera inset added in c2b9735: panel measured in .global -> panelFrame -> panelCameraInset -> Map safeAreaPadding -> layout invalidation -> new global frame An interactive swipe translates the page, so the panel's global position changed every frame, cleared the half-point guard, and re-framed the map again. Assigning `selection` in code swaps pages without ever producing those intermediate positions, which is exactly why every test passed while the real gesture was broken. Three rounds of green simulator runs described a build that locked up in ordinary use. The inset only ever needed to know how much of the display the panel covers, and that follows from its height plus the gap beneath it — both invariant under translation. So the cycle cannot close: moving the page no longer changes anything the map is told. The inset is also clamped to three quarters of the display. A status panel has no business claiming more, and a future measurement fault should degrade the framing rather than starve MapKit of anywhere to put the camera. Placement is unchanged: the fix renders at 91.25 pt against 91.2 pt before. Adam confirmed by hand that the gesture no longer wedges, and the same capture now yields 10,146 lines with 704 map reconfigurations. --- ios/MeshMapperWatch/MapPage.swift | 57 ++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index a2a5e33..868d685 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -88,9 +88,9 @@ struct MapPage: View { } } - /// The panel's frame in global coordinates, so the camera can keep the fix - /// out from behind it. - @State private var panelFrame: CGRect = .zero + /// 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 @State private var latchedTopSafeAreaInset: CGFloat = 0 @State private var currentTopSafeAreaInset: CGFloat = 0 @State private var bottomSafeAreaInset: CGFloat = 0 @@ -98,25 +98,42 @@ struct MapPage: View { /// 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. - private struct PanelFrameKey: PreferenceKey { - static let defaultValue: CGRect = .zero - static func reduce(value: inout CGRect, nextValue: () -> CGRect) { + /// + /// 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 != .zero { value = next } + 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. /// - /// Measured rather than assumed: the panel's height changes with its content - /// — one column or two, with or without heard rows — and a stale constant - /// would drift the fix off centre exactly when the panel grew. Before the - /// first measurement this is zero, which simply centres on the full display - /// for a frame. + /// 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. private var panelCameraInset: CGFloat { - guard panelFrame.height > 0 else { return 0 } - return max(0, WKInterfaceDevice.current().screenBounds.height - panelFrame.minY) + guard panelHeight > 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, panelHeight + gapBeneath), limit) } /// The placement scales with the estimated corner radius, putting every @@ -523,9 +540,9 @@ struct MapPage: View { map(proxy) mapOverlay(proxy) } - .onPreferenceChange(PanelFrameKey.self) { frame in - guard abs(frame.minY - panelFrame.minY) > 0.5 || panelFrame.height == 0 else { return } - panelFrame = frame + .onPreferenceChange(PanelHeightKey.self) { height in + guard abs(height - panelHeight) > 0.5 else { return } + panelHeight = height recenterIfFollowing(proxy) } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in @@ -610,14 +627,16 @@ struct MapPage: View { // 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 PanelFrameKey can move. + // panel's signed-off placement nor its measured height can move. .alignmentGuide(.top) { dimensions in dimensions[.bottom] + 3 } } .background( GeometryReader { geo in - Color.clear.preference(key: PanelFrameKey.self, value: geo.frame(in: .global)) + // 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) } ) } From 8430f96cfabb75ac661b42cfe752615d418d1f5e Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 09:28:56 -0700 Subject: [PATCH 49/71] Stop the map camera resting at a continental span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `camera` started `.automatic` and `recenterIfFollowing` was the only thing that ever assigned a region, so its three early returns each left the camera untouched. `.automatic` fits every annotation, which on hardware meant 34.4 degrees of latitude — about 3,800 km — and nothing recovered from it. Twenty consecutive continental callbacks across five wrist raises; only a manual Crown zoom escaped. Both triggers are now confirmed on a Series 9 and both are handled: Follow off reproduced in the simulator, which had never shown this because nobody had turned Follow off there fix == nil caught on device, `.automatic` reporting a 58.4-degree fit `anchorCameraIfNeeded` asserts a region once per native-map lifetime from a fresh stored centre, else the live fix, else a stale stored centre, so `isFollowing` governs tracking rather than whether any region is asserted at all. The centre persists in `WatchSettings` as one array — two keys can tear if the app is killed between writes — with a timestamp, because this map cannot pan and opening on a centre the wearer has travelled away from strands them somewhere they can only zoom. The span handshake is unchanged and still load-bearing: the `.automatic` fit arrives first on every rebuild and is rejected by the 25% gate, so a broken camera never corrupts the saved zoom. Verified in the logs as `confirmed N drift 3789.0%` followed by our own region at `drift 0.0%`. Also here, all measured rather than reasoned: - Past roughly 22 degrees of span MapKit shifts the camera centre north and leaves it there — 8.2 km at 24.5 degrees, 14.0 km at 25.0 — and the shift survived every later zoom including one back in to street level. It is now repaired after each Crown zoom, preserving the rendered span so correcting the centre does not undo the zoom. - The zoom floor drops to 0.0002. The Crown reaches 0.000230 degrees (~26 m) and the old 0.0005 floor clamped it, so zooming in snapped back out. The ceiling stays 0.5: zooming out past it still works, only persistence caps. - `MapReader` is gone. Camera placement moved to `.safeAreaPadding`, nothing called `proxy.convert`, and the proxy was threaded through six functions unread. - `WakeLog` and a persisted Instruments toggle, because launch arguments live in `NSArgumentDomain` and evaporate when watchOS relaunches the app, which is exactly what a wrist-down test provokes. - No main-actor hitch detector. One was written and removed: it fired on every wake at almost exactly the wrist-down duration, because it was timing the app being suspended rather than the main actor being blocked. --- ios/MeshMapperWatch/MapPage.swift | 417 +++++++++++++++++++++-- ios/MeshMapperWatch/SampleSnapshot.swift | 9 + ios/MeshMapperWatch/SettingsPage.swift | 4 + ios/MeshMapperWatch/WatchSettings.swift | 147 +++++++- 4 files changed, 543 insertions(+), 34 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 868d685..d96cbb6 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -46,11 +46,50 @@ struct MapPage: View { /// 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 @@ -263,6 +302,19 @@ struct MapPage: View { 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. + 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) } @@ -286,25 +338,45 @@ struct MapPage: View { } .onChange(of: isLuminanceReduced) { _, reduced in 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 } .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 reader inside this branch: constructing even map + // Keep the map inside this branch: constructing even map // infrastructure behind the readout would defeat its battery purpose. // - // DECIDE BEFORE OPENING A PR: this reader is now vestigial. Camera - // placement moved to safe-area insets, so nothing calls - // `proxy.convert` and the proxy is threaded through five functions - // unused. Removing it is a hierarchy change around a map whose launch - // and framing behaviour was verified by measurement, so it wants its - // own change and its own A/B — not a quiet tidy-up inside another one. - MapReader { proxy in - mapContent(proxy) - } + // 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 } @@ -535,22 +607,57 @@ struct MapPage: View { armedToolbarControl = nil } - private func mapContent(_ proxy: MapProxy) -> some View { + private var mapContent: some View { ZStack { - map(proxy) - mapOverlay(proxy) + map + mapOverlay } .onPreferenceChange(PanelHeightKey.self) { height in guard abs(height - panelHeight) > 0.5 else { return } panelHeight = height - recenterIfFollowing(proxy) + recenterIfFollowing() } .onChange(of: snapshot?.geo.you.map { "\($0.lat),\($0.lon)" }) { _, _ in - recenterIfFollowing(proxy) + recenterIfFollowing() + // 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 { - recenterIfFollowing(proxy) + // 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 + recenterIfFollowing() + anchorCameraIfNeeded() + #if DEBUG + WakeLog.note("camera-after \(cameraStateDescription)") + #endif } + #if DEBUG + // Separates "built twice" from "built, torn down, rebuilt". Every device + // wake logs two appearances 50-70 ms apart and the simulator logs one, so + // the shape of the pair decides where to look: an interleaved disappear + // means a genuine teardown, two bare appearances mean two live instances. + .onDisappear { WakeLog.note("map-subtree-disappeared") } + #endif } private var readoutContent: some View { @@ -613,11 +720,11 @@ struct MapPage: View { /// 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 func mapOverlay(_ proxy: MapProxy) -> some View { + private var mapOverlay: some View { VStack(spacing: 0) { HStack { Spacer(minLength: 0) - recenterButton(proxy) + recenterButton } Spacer(minLength: 0) statusPanel @@ -896,10 +1003,10 @@ struct MapPage: View { private var heard: [WatchHeardNode] { snapshot?.geo.heard ?? [] } @ViewBuilder - private func recenterButton(_ proxy: MapProxy) -> some View { + private var recenterButton: some View { if !isFollowing, fix != nil { Button { - recenterIfFollowing(proxy, force: true) + recenterIfFollowing(force: true) } label: { Image(systemName: "location.fill") .font(.system(size: 10)) @@ -912,7 +1019,7 @@ struct MapPage: View { // MARK: - Map - private func map(_ proxy: MapProxy) -> some View { + 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 @@ -927,7 +1034,11 @@ struct MapPage: View { // 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 and a Crown zoom anchors on it instead of sliding it across. + // 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 @@ -936,7 +1047,9 @@ struct MapPage: View { .safeAreaPadding(.top, currentTopSafeAreaInset) .safeAreaPadding(.bottom, panelCameraInset) .onMapCameraChange(frequency: .onEnd) { context in - noteRenderedRegion(context.region) + // `.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 @@ -1027,19 +1140,100 @@ struct MapPage: View { // MARK: - Camera - private func recenterIfFollowing(_ proxy: MapProxy, force: Bool = false) { + private func recenterIfFollowing(force: Bool = false) { + // 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. - programmaticCenter = fix - let region = MKCoordinateRegion(center: fix, span: currentSpan) - - if force { - // A tap is a rare, explicit request to move the map, so animation shows - // the wearer what their action changed. Automatic follow is different: - // the fix coordinate has already changed in this frame, and animating - // the camera after it makes the puck wander before the map catches up. + // + // A tap is a rare, explicit request to move the map, so animation shows the + // wearer what their action changed. Automatic follow is different: the fix + // coordinate has already changed in this frame, and animating the camera + // after it makes the puck wander before the map catches up. + applyRegion(center: fix, 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 + ) { + 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) } @@ -1050,6 +1244,17 @@ struct MapPage: View { } } + #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 @@ -1063,7 +1268,50 @@ struct MapPage: View { /// 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) { + 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 @@ -1075,6 +1323,16 @@ struct MapPage: View { 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 { @@ -1090,6 +1348,48 @@ struct MapPage: View { 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. @@ -1395,3 +1695,54 @@ private struct RepeaterPin: View { } } } + +#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/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index 1ac7991..c234687 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -24,6 +24,15 @@ import Foundation /// 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. diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 571b8a1..855ecb0 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -72,6 +72,10 @@ struct SettingsPage: View { // 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 } diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index 17f78ee..f4eca88 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -1,3 +1,4 @@ +import CoreLocation import Foundation import SwiftUI @@ -14,6 +15,7 @@ final class WatchSettings { 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" @@ -22,7 +24,23 @@ final class WatchSettings { /// Roughly 250 m north-south: one degree of latitude is about 111,320 m. static let defaultMapLatitudeDelta = 0.00225 - private static let mapLatitudeDeltaLimits = 0.0005...0.5 + /// 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. @@ -148,6 +166,113 @@ final class WatchSettings { } } + /// 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) } } @@ -179,6 +304,26 @@ final class WatchSettings { 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 From 591b9ed80c93813cd8f89fef7ac849dd5d8be29d Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 12:50:55 -0700 Subject: [PATCH 50/71] Show why the phone is or is not talking to the watch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone stopped sending snapshots for two hours and said nothing. The cause was `WCSession.isWatchAppInstalled` reporting false, so `WatchSessionManager.send` returned false and Dart's `schedule()` bailed before attempting anything. Neither side logs that: the bridge had no logging at all, and native only logs when a real send attempt fails, which never happened. `statusDictionary()` held the exact answer, handed it to Dart, and Dart showed it nowhere. Diagnosis took an hour and three wrong theories — a WCSession pairing fault, a second app install competing for the radio, two sources of truth for `isConnected` — none of which survived comparing file timestamps in the app containers. This is the surface that makes that a one-minute lookup instead. Settings gains an Apple Watch section listing the five native WCSession values, Dart's derived `canSync`, and — the point of the whole thing — which of `activated`, `paired` and `installed` is failing when it is false. Plus time since the last successful send, time since the last availability change, and whether that send was delivered or refused. Reachability is shown but deliberately excluded from the gate, because it is not one of the three conditions and mistaking it for one is exactly the wrong turn taken during the incident. The entry appears only when a watch is paired now or has ever been paired. The history flag is one-way on purpose: an unpaired watch is precisely when this needs to stay reachable, so no later native false may erase it. Also here: `statusDictionary()` now returns `activated` from its no-session branch too, so Dart never reads a missing key; and `_applyAvailability` logs the full status map on change only, never per send — the payload design exists to avoid chatty updates and this must not undo it. Tests cover both directions: the healthy gate, and the incident-shaped state where `installed` is false and the screen must name it. --- ios/Runner/WatchSessionManager.swift | 8 +- lib/providers/app_state_provider.dart | 59 ++++++ lib/screens/settings_screen.dart | 20 ++ lib/screens/watch_diagnostics_screen.dart | 193 ++++++++++++++++++ lib/services/watch/watch_bridge_service.dart | 102 ++++++++- .../watch_diagnostics_screen_test.dart | 45 ++++ .../watch/watch_wire_contract_test.dart | 52 ++++- 7 files changed, 470 insertions(+), 9 deletions(-) create mode 100644 lib/screens/watch_diagnostics_screen.dart create mode 100644 test/screens/watch_diagnostics_screen_test.dart diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index bae3aab..cca3a7c 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -128,7 +128,13 @@ final class WatchSessionManager: NSObject { private func statusDictionary() -> [String: Any] { guard let session else { - return ["supported": false, "paired": false, "installed": false, "reachable": false] + return [ + "supported": false, + "paired": false, + "installed": false, + "reachable": false, + "activated": false, + ] } return [ "supported": true, diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 65c3389..d7aad0a 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -134,6 +134,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final LiveActivityService _liveActivityService = LiveActivityService(); final WatchBridgeService _watchBridge = WatchBridgeService(); + bool _hasEverPairedWatch = false; /// Last position sent to the watch, held until the fix moves far enough to /// be worth an update. See [_resolveWatchPosition]. @@ -555,6 +556,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; @@ -1713,6 +1718,38 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { } } + 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, @@ -2146,6 +2183,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _timerListenable.addListener(_handleLiveActivityTimerChange); } if (_watchBridge.isSupportedPlatform) { + _watchBridge.diagnostics.addListener(_handleWatchDiagnosticsChanged); _watchBridge.attachCommandHandler( _handleWatchCommand, onRefusal: _emitWatchFailure, @@ -2240,6 +2278,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { // Load user preferences debugLog('[INIT] Loading preferences...'); await _loadPreferences(); + await _loadWatchPairingPreference(); await _loadDeviceAntennaPreferences(); await _loadDevicePowerOverrides(); await _loadDeviceRealNames(); @@ -8435,6 +8474,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); @@ -8940,6 +8998,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { _isDisposed = true; _timerListenable.removeListener(_handleLiveActivityTimerChange); _liveActivityService.dispose(); + _watchBridge.diagnostics.removeListener(_handleWatchDiagnosticsChanged); _watchBridge.dispose(); WidgetsBinding.instance.removeObserver(this); _adapterStateSubscription?.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/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 9083395..c25c287 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -19,6 +19,43 @@ typedef WatchCommandRefusalHandler = void Function(String reason); typedef WatchAvailabilityHandler = void Function(bool available); typedef WatchSnapshotDeliveryHandler = void Function(WatchSnapshot snapshot); +/// Read-only evidence from both sides of the phone-to-watch bridge. +/// +/// These timestamps intentionally do not share the transport's throttle and +/// dedupe fields. Those fields are cleared when WatchConnectivity changes +/// state, while a diagnostic must retain the last known-good send across the +/// exact outage that caused the state change. +@immutable +class WatchDiagnosticStatus { + const WatchDiagnosticStatus({ + this.supported = false, + this.paired = false, + this.installed = false, + this.reachable = false, + this.activated = false, + this.canSync = false, + this.lastSuccessfulSendAt, + this.lastAvailabilityChangedAt, + this.lastSendDelivered, + }); + + final bool supported; + final bool paired; + final bool installed; + final bool reachable; + final bool activated; + final bool canSync; + final DateTime? lastSuccessfulSendAt; + final DateTime? lastAvailabilityChangedAt; + final bool? lastSendDelivered; + + List get failingSyncConditions => [ + if (!activated) 'activated', + if (!paired) 'paired', + if (!installed) 'installed', + ]; +} + /// Owns the Flutter↔WatchConnectivity bridge and coalesces noisy app state. /// /// Deliberately mirrors [LiveActivityService]'s shape — fingerprint dedupe, @@ -55,6 +92,12 @@ class WatchBridgeService { bool _disposed = false; bool _didReconcileNativeState = false; bool _canSync = false; + Map? _lastNativeStatus; + DateTime? _lastSuccessfulSendAt; + DateTime? _lastAvailabilityChangedAt; + bool? _lastSendDelivered; + final ValueNotifier _diagnostics = + ValueNotifier(const WatchDiagnosticStatus()); DateTime? _mapGeoSuppressedAt; double? _lastMapGeoClaimIssuedAtMs; Future _operationChain = Future.value(); @@ -65,6 +108,7 @@ class WatchBridgeService { bool get isSupportedPlatform => !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; bool get canSync => isSupportedPlatform && _canSync; + ValueListenable get diagnostics => _diagnostics; /// Whether the next payload must carry map-only geography. /// @@ -204,16 +248,39 @@ class WatchBridgeService { } } + /// Re-reads the local WCSession properties for the diagnostic surface. + /// This does not send a snapshot or bypass the existing availability gate. + Future refreshAvailability() => _refreshAvailability(); + void _applyAvailability( Object? raw, { bool refreshNativeState = false, }) { if (raw is! Map) return; - final available = raw['activated'] == true && - raw['paired'] == true && - raw['installed'] == true; - if (available == _canSync && !refreshNativeState) return; - _canSync = available; + final status = { + 'supported': raw['supported'] == true, + 'paired': raw['paired'] == true, + 'installed': raw['installed'] == true, + 'reachable': raw['reachable'] == true, + 'activated': raw['activated'] == true, + }; + final available = + status['activated']! && status['paired']! && status['installed']!; + final availabilityChanged = available != _canSync; + final statusChanged = !mapEquals(_lastNativeStatus, status); + + if (availabilityChanged || refreshNativeState) _canSync = available; + if (statusChanged) { + _lastNativeStatus = Map.unmodifiable(status); + _lastAvailabilityChangedAt = DateTime.now(); + _publishDiagnostics(); + // This is deliberately tied to a changed native status map. Snapshot + // scheduling and explicit refreshes can call this path frequently, but + // repeated state adds no evidence and would hide the useful transition. + debugLog( + '[WATCH] Availability changed: status=$status canSync=$_canSync'); + } + if (!availabilityChanged && !refreshNativeState) return; // Native forgets its application-context cache whenever WatchConnectivity // reports a state change. Forget ours on the same notification even when @@ -230,6 +297,22 @@ class WatchBridgeService { _availabilityHandler?.call(available); } + void _publishDiagnostics() { + if (_disposed) return; + final status = _lastNativeStatus; + _diagnostics.value = WatchDiagnosticStatus( + supported: status?['supported'] ?? false, + paired: status?['paired'] ?? false, + installed: status?['installed'] ?? false, + reachable: status?['reachable'] ?? false, + activated: status?['activated'] ?? false, + canSync: canSync, + lastSuccessfulSendAt: _lastSuccessfulSendAt, + lastAvailabilityChangedAt: _lastAvailabilityChangedAt, + lastSendDelivered: _lastSendDelivered, + ); + } + void _rememberCommandId(String id) { _handledCommandIds.add(id); // Unbounded growth would leak across a long session. @@ -326,6 +409,8 @@ class WatchBridgeService { 'urgent': urgent, }); if (delivered != true) { + _lastSendDelivered = false; + _publishDiagnostics(); // Native can lose availability between status and send. Do not cache // a payload it refused. Re-query rather than guessing which condition // failed, so a transient context error cannot permanently close the @@ -336,7 +421,11 @@ class WatchBridgeService { _didReconcileNativeState = true; _lastPayload = encoded; _lastUrgencyKey = snapshot.urgencyKey; - _lastSentAt = DateTime.now(); + final sentAt = DateTime.now(); + _lastSentAt = sentAt; + _lastSuccessfulSendAt = sentAt; + _lastSendDelivered = true; + _publishDiagnostics(); _snapshotDeliveryHandler?.call(snapshot); } on MissingPluginException { // Expected on non-iOS hosts and in tests. @@ -377,5 +466,6 @@ class WatchBridgeService { _snapshotDeliveryHandler = null; _mapGeoSuppressedAt = null; _lastMapGeoClaimIssuedAtMs = null; + _diagnostics.dispose(); } } diff --git a/test/screens/watch_diagnostics_screen_test.dart b/test/screens/watch_diagnostics_screen_test.dart new file mode 100644 index 0000000..a0d3597 --- /dev/null +++ b/test/screens/watch_diagnostics_screen_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mesh_mapper/screens/watch_diagnostics_screen.dart'; +import 'package:mesh_mapper/services/watch/watch_bridge_service.dart'; + +/// Renders the diagnostic in the exact shape of the 2026-08-14 incident: +/// paired and reachable, but `installed` false, which silently closed the +/// sync gate for two hours with no log line anywhere. +void main() { + testWidgets('names the condition that closed the gate', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/watch_diag_test'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': false, + 'reachable': true, + }; + } + return null; + }); + final bridge = WatchBridgeService(channel: channel); + + await tester.pumpWidget( + MaterialApp(home: WatchDiagnosticsScreen(bridge: bridge)), + ); + await tester.pump(); + + expect(find.text('Failing condition: installed'), findsOneWidget); + expect(find.text('installed'), findsOneWidget); + expect(find.text('canSync'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + debugDefaultTargetPlatformOverride = null; + }); +} diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 75831f4..e7c156f 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -530,9 +530,11 @@ void main() { .setMockMethodCallHandler(channel, (call) async { if (call.method == 'status') { return { + 'supported': true, 'activated': nativeAvailable, 'paired': nativeAvailable, 'installed': nativeAvailable, + 'reachable': nativeAvailable, }; } if (call.method == 'sync') { @@ -604,7 +606,13 @@ void main() { channel.name, channel.codec.encodeMethodCall(const MethodCall( 'availabilityChanged', - {'activated': true, 'paired': true, 'installed': true}, + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + }, )), null, ); @@ -613,6 +621,36 @@ void main() { expect(changes, [true]); }); + test('diagnostics identify the exact condition closing the sync gate', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + final result = await TestDefaultBinaryMessengerBinding + .instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'availabilityChanged', + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': false, + 'reachable': true, + }, + )), + null, + ); + + expect(result, isNotNull); + expect(bridge.canSync, isFalse); + expect(bridge.diagnostics.value.supported, isTrue); + expect(bridge.diagnostics.value.reachable, isTrue); + expect(bridge.diagnostics.value.failingSyncConditions, ['installed']); + expect(bridge.diagnostics.value.lastAvailabilityChangedAt, isNotNull); + }); + test('a native false is not cached as a delivered snapshot', () async { bridge.attachCommandHandler((_) => null); await Future.delayed(Duration.zero); @@ -636,6 +674,8 @@ void main() { expect(builds, 2); expect(syncCalls, 2, reason: 'native refused both; neither payload was delivered'); + expect(bridge.diagnostics.value.lastSendDelivered, isFalse); + expect(bridge.diagnostics.value.lastSuccessfulSendAt, isNull); }); test('a watch state change resends even when availability stays true', @@ -662,7 +702,13 @@ void main() { channel.name, channel.codec.encodeMethodCall(const MethodCall( 'availabilityChanged', - {'activated': true, 'paired': true, 'installed': true}, + { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + }, )), null, ); @@ -717,6 +763,8 @@ void main() { expect(delivered, same(snapshot)); expect(syncCalls, 1); + expect(bridge.diagnostics.value.lastSendDelivered, isTrue); + expect(bridge.diagnostics.value.lastSuccessfulSendAt, isNotNull); }); test('accepted commands reach the handler', () async { From ebc09fb1abd6eb74ff923908baed089aa9f70a77 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 14:03:43 -0700 Subject: [PATCH 51/71] Stop the puck lagging the pings beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pings carry their own transmit-time GPS while the puck was held still until the fix moved fifteen metres, so on a walk the pings led the wearer in the direction of travel. Tightening the watch's zoom floor to about twenty-two metres of visible latitude turned a long-standing offset into two thirds of the display. The fifteen-metre gate exists to stop a parked phone's GPS jitter waking the radio, and it did that by making the payload identical so the bridge deduped it. But a new ping changes the payload by itself: that packet goes out regardless, and sending a stale puck inside it buys nothing. So ask the question in the transport instead of answering it in the payload. WatchBridgeService now encodes the fingerprint a second time with geo.you removed and skips the send only when everything else is byte-identical and the fix has moved less than the threshold. The anchor is the fix the watch actually received, so two eleven-metre steps add up and go, and a refused send cannot silently consume the wearer's next fifteen metres. Position-derived fields keep the old gate. distanceM is a full-precision double and the repeater list is distance-sorted, so feeding those the live fix would move the payload on every jitter in fields the transport cannot recognise as position — a send every two seconds from a phone on a table. _resolveRankingPosition holds them still; fifteen metres is invisible in a kilometre-scale readout and was only ever visible in the puck. movedEnough and distanceMeters move to WatchWire beside minMoveMeters, so the transport can apply the gate without importing a builder that reaches back into AppStateProvider. --- lib/providers/app_state_provider.dart | 83 ++++++-- lib/services/watch/watch_bridge_service.dart | 72 +++++++ lib/services/watch/watch_geo_builder.dart | 43 +---- lib/services/watch/watch_models.dart | 41 ++++ .../watch/watch_geo_builder_test.dart | 6 +- .../watch/watch_wire_contract_test.dart | 180 ++++++++++++++++++ 6 files changed, 360 insertions(+), 65 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index d7aad0a..4a70f74 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -136,9 +136,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { final WatchBridgeService _watchBridge = WatchBridgeService(); bool _hasEverPairedWatch = false; - /// Last position sent to the watch, held until the fix moves far enough to - /// be worth an update. See [_resolveWatchPosition]. + /// 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. @@ -1326,6 +1330,7 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 @@ -1347,8 +1352,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaterByHex: repeaterByHex, topAt: _topRepeatersOverlayUpdatedAt, rxAt: _liveActivityRxUpdatedAt, - lat: position?.lat, - lon: position?.lon, + lat: ranking?.lat, + lon: ranking?.lon, ); // The readout still needs the fix and Top Heard, but none of the arrays @@ -1380,8 +1385,8 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { repeaters: WatchGeoBuilder.buildRepeaters( repeaters: _repeaters, heardThisCycle: heardIds, - lat: position?.lat, - lon: position?.lon, + lat: ranking?.lat, + lon: ranking?.lon, ), heard: heard, linkedRepeaterIds: [ @@ -1393,26 +1398,27 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - /// Current fix, held still until it moves meaningfully. + /// 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. /// - /// Returning the previous position leaves the payload fingerprint unchanged, - /// so the bridge's dedupe suppresses the send. A parked phone therefore - /// stops talking to the watch instead of streaming GPS jitter at it. + /// 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 previous = _lastWatchPosition; - if (previous != null && - !WatchGeoBuilder.movedEnough( - lastLat: previous.lat, - lastLon: previous.lon, - lat: position.latitude, - lon: position.longitude, - )) { - return previous; - } - final resolved = WatchPosition( lat: position.latitude, lon: position.longitude, @@ -1426,6 +1432,41 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { 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 diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index c25c287..b6ef048 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -86,6 +86,12 @@ class WatchBridgeService { WatchSnapshotDeliveryHandler? _snapshotDeliveryHandler; String? _lastPayload; + + /// The delivered payload with the fix removed, plus the fix that went with + /// it. Together they answer "did anything but our position change?", which is + /// what the movement gate needs and the whole-payload fingerprint cannot say. + String? _lastPayloadWithoutFix; + ({double lat, double lon})? _lastSentFix; String? _lastUrgencyKey; DateTime? _lastSentAt; DateTime? _lastBuiltAt; @@ -290,6 +296,8 @@ class WatchBridgeService { _mapGeoSuppressedAt = null; _lastMapGeoClaimIssuedAtMs = null; _lastPayload = null; + _lastPayloadWithoutFix = null; + _lastSentFix = null; _lastUrgencyKey = null; _lastSentAt = null; _lastBuiltAt = null; @@ -390,6 +398,20 @@ class WatchBridgeService { final encoded = jsonEncode(fingerprint); if (encoded == _lastPayload) return; + // The movement gate. A stationary GPS jitters by a few metres for as long + // as the phone is switched on, and forwarding that would keep the watch + // radio busy for a puck that never visibly moves. + // + // It is deliberately expressed as "nothing but the fix changed, and the fix + // did not move far enough" rather than as a stale position in the payload, + // which is where it used to live. Those are the same suppression and a very + // different packet: a new ping defeats the dedupe by itself, and the older + // arrangement then sent that ping alongside a puck up to 15 m behind it — + // visibly so once the watch's zoom floor reached ~22 m of latitude. + final fix = _fixOf(fingerprint); + final withoutFix = _encodeWithoutFix(fingerprint); + if (withoutFix == _lastPayloadWithoutFix && !_fixMovedEnough(fix)) return; + final urgent = snapshot.urgencyKey != _lastUrgencyKey; final sentAt = _lastSentAt; if (!urgent && sentAt != null) { @@ -420,6 +442,10 @@ class WatchBridgeService { } _didReconcileNativeState = true; _lastPayload = encoded; + // Anchored to what the watch actually received, so a refused or dropped + // send cannot quietly consume the wearer's next 15 m of movement. + _lastPayloadWithoutFix = withoutFix; + _lastSentFix = fix; _lastUrgencyKey = snapshot.urgencyKey; final sentAt = DateTime.now(); _lastSentAt = sentAt; @@ -448,12 +474,58 @@ class WatchBridgeService { } finally { _didReconcileNativeState = true; _lastPayload = null; + _lastPayloadWithoutFix = null; + _lastSentFix = null; _lastUrgencyKey = null; _lastSentAt = null; _lastBuiltAt = null; } } + /// Whether the fix has moved far enough to be worth a send on its own. + /// + /// Measured against the fix the watch last *received*, not the last one the + /// phone computed, so a parked phone's jitter can never accumulate its way + /// past the threshold one sub-threshold step at a time. + bool _fixMovedEnough(({double lat, double lon})? fix) { + final last = _lastSentFix; + if (fix == null || last == null) return true; + return WatchWire.movedEnough( + lastLat: last.lat, + lastLon: last.lon, + lat: fix.lat, + lon: fix.lon, + ); + } + + static Map? _geoOf(Map fingerprint) { + final geo = fingerprint['geo']; + return geo is Map ? Map.from(geo) : null; + } + + static ({double lat, double lon})? _fixOf(Map fingerprint) { + final you = _geoOf(fingerprint)?['you']; + if (you is! Map) return null; + final lat = you['lat']; + final lon = you['lon']; + if (lat is! num || lon is! num) return null; + return (lat: lat.toDouble(), lon: lon.toDouble()); + } + + /// The payload with the wearer's position taken out, so two of them can be + /// compared for "did anything else change?". + /// + /// The whole `you` object goes, not just its coordinates: heading, accuracy + /// and fix time all drift on a phone that has not moved, and treating any of + /// them as a reason to send would defeat the gate they are travelling with. + static String _encodeWithoutFix(Map fingerprint) { + final geo = _geoOf(fingerprint); + if (geo == null) return jsonEncode(fingerprint); + return jsonEncode( + Map.from(fingerprint)..['geo'] = (geo..remove('you')), + ); + } + void dispose() { _disposed = true; _scheduledUpdate?.cancel(); diff --git a/lib/services/watch/watch_geo_builder.dart b/lib/services/watch/watch_geo_builder.dart index d9118cf..3bd455c 100644 --- a/lib/services/watch/watch_geo_builder.dart +++ b/lib/services/watch/watch_geo_builder.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import '../../models/log_entry.dart'; import '../../models/ping_data.dart'; import '../../models/repeater.dart'; @@ -15,29 +13,6 @@ import 'watch_models.dart'; class WatchGeoBuilder { WatchGeoBuilder._(); - /// Great-circle distance in metres. - /// - /// Local rather than `Geolocator.distanceBetween` to keep this file free of - /// plugin imports; the maths is identical. - static double distanceMeters( - double lat1, - double lon1, - double lat2, - double lon2, - ) { - const earthRadius = 6371000.0; - final dLat = _toRadians(lat2 - lat1); - final dLon = _toRadians(lon2 - lon1); - final a = math.sin(dLat / 2) * math.sin(dLat / 2) + - math.cos(_toRadians(lat1)) * - math.cos(_toRadians(lat2)) * - math.sin(dLon / 2) * - math.sin(dLon / 2); - return earthRadius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); - } - - static double _toRadians(double degrees) => degrees * math.pi / 180.0; - /// Colour for a ping marker, matching the iOS map's `_coverageStatusColor`. static WatchColor pingColor(String kind, bool success) { switch (kind) { @@ -219,7 +194,7 @@ class WatchGeoBuilder { final ranked = located .map((r) => ( repeater: r, - distance: distanceMeters(lat, lon, r.lat, r.lon), + distance: WatchWire.distanceMeters(lat, lon, r.lat, r.lon), )) .toList() ..sort((a, b) => a.distance.compareTo(b.distance)); @@ -346,7 +321,7 @@ class WatchGeoBuilder { lon != null && repeater != null && repeater.hasLocation) { - distance = distanceMeters(lat, lon, repeater.lat, repeater.lon); + distance = WatchWire.distanceMeters(lat, lon, repeater.lat, repeater.lon); } return WatchHeardNode( @@ -385,18 +360,4 @@ class WatchGeoBuilder { return index; } - /// True when the fix moved far enough to be worth an update. - /// - /// A stationary GPS jitters by a few metres indefinitely; without this gate - /// a parked phone would keep the watch radio busy for no visible change. - static bool movedEnough({ - required double? lastLat, - required double? lastLon, - required double lat, - required double lon, - double thresholdMeters = WatchWire.minMoveMeters, - }) { - if (lastLat == null || lastLon == null) return true; - return distanceMeters(lastLat, lastLon, lat, lon) >= thresholdMeters; - } } diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index f349d75..9d05b28 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import '../live_activity/live_activity_models.dart'; import 'watch_color.dart'; @@ -41,6 +43,45 @@ class WatchWire { /// changes and new pings always go through; this only suppresses the /// jitter of a stationary GPS. static const double minMoveMeters = 15.0; + + /// True when the fix moved far enough to be worth an update on its own. + /// + /// Lives here beside [minMoveMeters], and not with the rest of the geography + /// helpers, so the transport can apply the gate without importing anything + /// that knows what a ping is. + static bool movedEnough({ + required double? lastLat, + required double? lastLon, + required double lat, + required double lon, + double thresholdMeters = minMoveMeters, + }) { + if (lastLat == null || lastLon == null) return true; + return distanceMeters(lastLat, lastLon, lat, lon) >= thresholdMeters; + } + + /// Great-circle distance in metres. + /// + /// Local rather than `Geolocator.distanceBetween` to keep this file free of + /// plugin imports; the maths is identical. + static double distanceMeters( + double lat1, + double lon1, + double lat2, + double lon2, + ) { + const earthRadius = 6371000.0; + final dLat = _toRadians(lat2 - lat1); + final dLon = _toRadians(lon2 - lon1); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRadians(lat1)) * + math.cos(_toRadians(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return earthRadius * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + static double _toRadians(double degrees) => degrees * math.pi / 180.0; } /// Start modes the phone may explicitly offer to the wrist. diff --git a/test/services/watch/watch_geo_builder_test.dart b/test/services/watch/watch_geo_builder_test.dart index dcc4543..6c657b4 100644 --- a/test/services/watch/watch_geo_builder_test.dart +++ b/test/services/watch/watch_geo_builder_test.dart @@ -611,7 +611,7 @@ void main() { group('movedEnough', () { test('always sends the first fix', () { expect( - WatchGeoBuilder.movedEnough( + WatchWire.movedEnough( lastLat: null, lastLon: null, lat: 47.6, @@ -623,7 +623,7 @@ void main() { test('suppresses stationary GPS jitter', () { expect( - WatchGeoBuilder.movedEnough( + WatchWire.movedEnough( lastLat: 47.6, lastLon: -122.3, lat: 47.60002, @@ -635,7 +635,7 @@ void main() { test('passes once the fix moves past the threshold', () { expect( - WatchGeoBuilder.movedEnough( + WatchWire.movedEnough( lastLat: 47.6, lastLon: -122.3, lat: 47.6005, diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index e7c156f..2612905 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -926,4 +926,184 @@ void main() { expect(reply?['reason'], 'Passive Only'); }); }); + + /// The gate that stops a parked phone streaming GPS jitter at the watch, and + /// the reason it lives in the transport rather than in the payload builder. + /// + /// It used to be applied by handing the watch a *stale* position until the + /// fix had moved 15 m, which suppressed the send by making the payload + /// identical. A new ping defeats that dedupe by itself, so the packet went + /// out anyway carrying a puck up to 15 m behind the ping beside it. Adam saw + /// it on a walk: "the pings appear ahead of the current location and center". + group('movement gate', () { + late WatchBridgeService bridge; + late MethodChannel channel; + late List> sent; + + /// Long enough for the 2 s non-urgent throttle to clear and for the + /// bridge's own retry timer to run, so "no send" means suppressed rather + /// than merely deferred. A fresh bridge has no throttle history at all, so + /// its first push needs neither. + const settle = Duration(milliseconds: 2500); + const firstPush = Duration(milliseconds: 100); + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + channel = const MethodChannel('meshmapper/watch_move_test'); + bridge = WatchBridgeService(channel: channel); + sent = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'status') { + return { + 'supported': true, + 'activated': true, + 'paired': true, + 'installed': true, + 'reachable': true, + }; + } + if (call.method == 'sync') { + final args = call.arguments as Map; + sent.add(args['payload'] as Map); + return true; + } + return null; + }); + bridge.attachCommandHandler((_) => null); + }); + + tearDown(() { + debugDefaultTargetPlatformOverride = null; + bridge.dispose(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + WatchGeo geoAt( + double lat, { + required int fixedAtMs, + List pings = const [], + }) => + WatchGeo( + you: WatchPosition( + lat: lat, + lon: -122.3, + fixedAt: DateTime.fromMillisecondsSinceEpoch(fixedAtMs), + ), + pings: pings, + repeaters: const [], + heard: const [], + linkedRepeaterIds: const [], + ); + + Future push(WatchGeo geo, {Duration wait = settle}) async { + final snapshot = _snapshot(geo: geo); + bridge.schedule( + () => snapshot, + urgencyKeyBuilder: () => snapshot.urgencyKey, + immediate: true, + ); + await Future.delayed(wait); + } + + double sentLat(int index) { + final geo = sent[index]['geo'] as Map; + final you = geo['you'] as Map; + return you['lat'] as double; + } + + test('suppresses jitter, but never a payload that is going out anyway', + () async { + // 1e-5 degrees of latitude is about 1.11 m here, so 47.60002 is roughly + // 2 m from the baseline and 47.6003 is roughly 33 m. + await push(geoAt(47.6, fixedAtMs: 1760000000000), wait: firstPush); + expect(sent, hasLength(1), reason: 'the first fix always goes'); + + // A newer fix time and a couple of metres. This is a stationary phone, + // and it must not reach the radio — including on the retry the throttle + // scheduled, which `settle` has already allowed to fire. + await push(geoAt(47.60002, fixedAtMs: 1760000030000)); + expect(sent, hasLength(1)); + + // The same two metres, now travelling with a ping. The ping alone + // defeats the dedupe, so this send happens either way; the assertion is + // that it carries the *current* fix rather than the last one sent. + await push(geoAt( + 47.60002, + fixedAtMs: 1760000060000, + pings: [ + WatchPing( + id: 'rx-1760000060000', + lat: 47.60002, + lon: -122.3, + kind: 'rx', + color: const WatchColor(0, 0, 255), + at: DateTime.fromMillisecondsSinceEpoch(1760000060000), + ), + ], + )); + expect(sent, hasLength(2)); + expect(sentLat(1), 47.60002, + reason: 'the puck must not lag the ping beside it'); + + // And the gate still opens on its own once the wearer has actually + // moved, with nothing else in the payload changing. + await push(geoAt(47.6003, fixedAtMs: 1760000090000)); + expect(sent, hasLength(3)); + expect(sentLat(2), 47.6003); + }); + + /// The gate can only recognise a change confined to the fix itself, which + /// is why `AppStateProvider._resolveRankingPosition` still holds a lagging + /// position behind every *derived* field. `WatchHeardNode.distanceM` is a + /// full-precision double, so feeding it the live fix would move the payload + /// on every GPS jitter and walk straight past this gate — one send per + /// throttle interval, from a phone sitting on a table. + test('cannot see through a derived field, so derived fields must not move', + () async { + WatchGeo geoWith(double lat, double distanceM) => WatchGeo( + you: WatchPosition( + lat: lat, + lon: -122.3, + fixedAt: DateTime.fromMillisecondsSinceEpoch(1760000000000), + ), + pings: const [], + repeaters: const [], + heard: [ + WatchHeardNode( + id: '4E5D', + typeColor: const WatchColor(0, 1, 0), + at: DateTime.fromMillisecondsSinceEpoch(1760000000000), + distanceM: distanceM, + ), + ], + linkedRepeaterIds: const [], + ); + + await push(geoWith(47.6, 1423.5), wait: firstPush); + expect(sent, hasLength(1)); + + // Two metres of jitter, with the distance readout recomputed from it. + await push(geoWith(47.60002, 1421.3)); + expect(sent, hasLength(2), + reason: 'a moved distanceM is indistinguishable from real news'); + }); + + test('measures from the last fix the watch received', () async { + await push(geoAt(47.6, fixedAtMs: 1760000000000), wait: firstPush); + expect(sent, hasLength(1)); + + // Two ~11 m steps in the same direction. Each is short of the threshold + // on its own, but the second lands ~22 m from the fix the watch actually + // holds, so it goes. Anchoring on the previous *computed* fix instead + // would suppress a wearer walking away one short step at a time. + await push(geoAt(47.6001, fixedAtMs: 1760000030000)); + expect(sent, hasLength(1)); + await push(geoAt(47.6002, fixedAtMs: 1760000060000)); + expect(sent, hasLength(2), reason: '~22 m from the delivered fix'); + expect(sentLat(1), 47.6002); + }); + }); } From 71dd8dd385051295f7912157bf3ddc3324b44726 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 14:03:59 -0700 Subject: [PATCH 52/71] Keep the map on screen when the display dims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam, on a walk: "the watch often falls back to the always on state very rapidly despite still being in the lifted position. This can make the map flicker on and disappear while a user is looking at it." It is not the wrist coming down. watchOS drops to reduced luminance about 5.9 seconds into a glance — thirty-three lifts measured, fourteen inside a half-second band — and the clock face does the same, so this is the platform's full-brightness window and there is nothing upstream to fix. The display stays legible. showsMap read the dim as nobody looking and tore the whole MapKit subtree down mid-glance. The map now survives the dim for twenty seconds. The geo claim does not: needsMapGeo reads luminance directly instead of going through showsMap, so the held map draws the pings the watch already has and the phone sends no more than before. MapKit staying constructed is a real cost, and twenty seconds a glance is the trade; a wrist-down's worth would not be. Four things had to hold at once, and hardware taught three of them. The hold cannot depend on a timer. watchOS runs the app at a much lower cadence once dimmed, so a boolean owned by a sleeping task would stay set for an entire wrist-down and leave a map as the Always-On surface. The predicate reads the clock. A clock is not enough either, because the last frame drawn stays on the display until something asks for another. A repeating TimelineView requests that render. Its entries are not the deadline, though: entering Always On changes the timeline cadence, which re-queries the schedule and delivers the next entry early. Treating that as the boundary expired the hold 0.82 to 1.05 seconds into every glance, thirteen for thirteen on a Series 9, while the simulator — which never enters Always On — passed every time. The clock decides; the entry only says when to look. The first dimmed frame must already be holding, because the timestamp is written by onChange, which runs after the body evaluation that first sees the dim. So a missing timestamp means hold. That default is fenced by hasBeenBright, which no view-lifecycle callback may clear: watchOS re-hosts this page at the dim, and clearing it there put the wearer back on the readout a second in. Fresh @State already covers what the reset was for. And nothing may change shape at the dim. The toolbar items were wrapped in an if, and removing them alters the toolbar's structure, which re-hosts the page and rebuilds all of MapKit beneath it — a subtree appearing 0.13 seconds after every dim, with a fresh camera anchor and basemap repaint, which the wearer saw as the map jumping as it faded. They now stay put and are hidden by value. Disabled and accessibility-hidden as well as transparent, because Start transmits on a single tap and an invisible control is still reachable by an assistive technology. -MeshMapperAutoDimAfter drives the transition in the simulator, which MeshMapperForceDimmed could not: it only sets the state a page is born into, so it exercised the cold-dimmed path and never the glance. Every fix above was found by measurement rather than reasoning, three of them only after hardware disagreed with a simulator that had signed the work off. Also records the settled cause of the double map construction. It is not the map: a bare probe above the branch doubles, and so does one in the readout on a launch with no MapKit anywhere, while probes on the NavigationStack and the TabView each fire once. The vertical pager builds its selected page twice and discards one, and nothing here can prevent it. --- ios/MeshMapperWatch/MapPage.swift | 355 ++++++++++++++++++++++++++++-- 1 file changed, 341 insertions(+), 14 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index d96cbb6..80702e6 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -27,19 +27,142 @@ struct MapPage: View { /// 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; reduced luminance selects the - /// same approved surface because MapKit is not worth its Always-On cost. + /// 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 + 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 + /// roughly double it, for a map the wearer was looking at either way. + 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." + /// + /// Twenty seconds covers a deliberate glance. It leaves the *radio* alone — + /// `needsMapGeo` above is untouched by the hold — but it is not free: MapKit + /// stays constructed and rendering for the duration, which is the cost the + /// Always-On readout was introduced to avoid. Twenty seconds of it per + /// glance is the trade; a wrist-down's worth would not be. + private static let dimmedMapHold: TimeInterval = 20 + + + /// 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 } - private var needsMapGeo: Bool { showsMap && isSelected } + /// 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 @@ -237,14 +360,30 @@ struct MapPage: View { 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 { - if !isLuminanceReduced { - ToolbarItem(placement: .topBarLeading) { - mainPageToggle - } - ToolbarItem(placement: .topBarTrailing) { - trailingToolbarButton - } + ToolbarItem(placement: .topBarLeading) { + mainPageToggle.hiddenWhileDimmed(isLuminanceReduced) + } + ToolbarItem(placement: .topBarTrailing) { + trailingToolbarButton.hiddenWhileDimmed(isLuminanceReduced) } } .background( @@ -308,6 +447,14 @@ struct MapPage: View { // `-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)) @@ -317,6 +464,10 @@ struct MapPage: View { } #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) @@ -327,6 +478,7 @@ struct MapPage: View { // 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 @@ -337,6 +489,7 @@ struct MapPage: View { 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 @@ -345,6 +498,14 @@ struct MapPage: View { 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 @@ -381,6 +542,64 @@ struct MapPage: View { 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 somewhat after twenty seconds. Nobody is looking by then — the + /// point is that it returns without needing the wrist. + @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? { @@ -607,6 +826,82 @@ struct MapPage: View { 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 @@ -653,9 +948,20 @@ struct MapPage: View { } #if DEBUG // Separates "built twice" from "built, torn down, rebuilt". Every device - // wake logs two appearances 50-70 ms apart and the simulator logs one, so - // the shape of the pair decides where to look: an interleaved disappear - // means a genuine teardown, two bare appearances mean two live instances. + // 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") } #endif } @@ -1529,6 +1835,27 @@ extension Comparable { } } +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 From 1d7bbe01cbd9fa2e6ad9ea2d62dbfdf21d0d79c2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 15:37:40 -0700 Subject: [PATCH 53/71] Stop the movement-gate tests racing the throttle They slept a fixed 2.5 seconds against the bridge's 2 second non-urgent throttle and the retry it schedules, which left about half a second of margin. That was enough until a loaded machine ate it, and a suite that fails one run in ten teaches people to re-run rather than to look. Only the assertions that nothing was sent actually need to wait, so they keep a fixed window comfortably past the throttle: "no send" has to mean suppressed rather than merely deferred. The ones expecting a send now poll for it instead, which is both faster and indifferent to how busy the machine is. --- .../watch/watch_wire_contract_test.dart | 92 +++++++++++-------- 1 file changed, 52 insertions(+), 40 deletions(-) diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 2612905..60cf578 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -940,12 +940,12 @@ void main() { late MethodChannel channel; late List> sent; - /// Long enough for the 2 s non-urgent throttle to clear and for the - /// bridge's own retry timer to run, so "no send" means suppressed rather - /// than merely deferred. A fresh bridge has no throttle history at all, so - /// its first push needs neither. - const settle = Duration(milliseconds: 2500); - const firstPush = Duration(milliseconds: 100); + /// Waiting out the 2 s non-urgent throttle is only necessary when the + /// assertion is that nothing was sent. A fixed wait tuned near that + /// boundary flakes on a loaded machine — this suite has already seen it — + /// so the positive cases poll instead and only "no send" pays a fixed cost. + const suppressionWindow = Duration(seconds: 3); + const sendTimeout = Duration(seconds: 8); setUp(() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -998,14 +998,33 @@ void main() { linkedRepeaterIds: const [], ); - Future push(WatchGeo geo, {Duration wait = settle}) async { + void schedule(WatchGeo geo) { final snapshot = _snapshot(geo: geo); bridge.schedule( () => snapshot, urgencyKeyBuilder: () => snapshot.urgencyKey, immediate: true, ); - await Future.delayed(wait); + } + + /// Polls for the send rather than sleeping a fixed interval. The bridge + /// reschedules instead of sending while the throttle is closed, so the + /// delay is real but its exact length is not the thing under test. + Future pushExpectingSend(WatchGeo geo, int total) async { + schedule(geo); + final deadline = DateTime.now().add(sendTimeout); + while (sent.length < total && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 25)); + } + expect(sent, hasLength(total)); + } + + /// There is nothing to poll for, so this waits past the throttle *and* the + /// retry it schedules: "no send" has to mean suppressed, not deferred. + Future pushExpectingSuppression(WatchGeo geo, int total) async { + schedule(geo); + await Future.delayed(suppressionWindow); + expect(sent, hasLength(total)); } double sentLat(int index) { @@ -1018,40 +1037,39 @@ void main() { () async { // 1e-5 degrees of latitude is about 1.11 m here, so 47.60002 is roughly // 2 m from the baseline and 47.6003 is roughly 33 m. - await push(geoAt(47.6, fixedAtMs: 1760000000000), wait: firstPush); - expect(sent, hasLength(1), reason: 'the first fix always goes'); + await pushExpectingSend(geoAt(47.6, fixedAtMs: 1760000000000), 1); // A newer fix time and a couple of metres. This is a stationary phone, // and it must not reach the radio — including on the retry the throttle // scheduled, which `settle` has already allowed to fire. - await push(geoAt(47.60002, fixedAtMs: 1760000030000)); - expect(sent, hasLength(1)); + await pushExpectingSuppression(geoAt(47.60002, fixedAtMs: 1760000030000), 1); // The same two metres, now travelling with a ping. The ping alone // defeats the dedupe, so this send happens either way; the assertion is // that it carries the *current* fix rather than the last one sent. - await push(geoAt( - 47.60002, - fixedAtMs: 1760000060000, - pings: [ - WatchPing( - id: 'rx-1760000060000', - lat: 47.60002, - lon: -122.3, - kind: 'rx', - color: const WatchColor(0, 0, 255), - at: DateTime.fromMillisecondsSinceEpoch(1760000060000), - ), - ], - )); - expect(sent, hasLength(2)); + await pushExpectingSend( + geoAt( + 47.60002, + fixedAtMs: 1760000060000, + pings: [ + WatchPing( + id: 'rx-1760000060000', + lat: 47.60002, + lon: -122.3, + kind: 'rx', + color: const WatchColor(0, 0, 255), + at: DateTime.fromMillisecondsSinceEpoch(1760000060000), + ), + ], + ), + 2, + ); expect(sentLat(1), 47.60002, reason: 'the puck must not lag the ping beside it'); // And the gate still opens on its own once the wearer has actually // moved, with nothing else in the payload changing. - await push(geoAt(47.6003, fixedAtMs: 1760000090000)); - expect(sent, hasLength(3)); + await pushExpectingSend(geoAt(47.6003, fixedAtMs: 1760000090000), 3); expect(sentLat(2), 47.6003); }); @@ -1082,27 +1100,21 @@ void main() { linkedRepeaterIds: const [], ); - await push(geoWith(47.6, 1423.5), wait: firstPush); - expect(sent, hasLength(1)); + await pushExpectingSend(geoWith(47.6, 1423.5), 1); // Two metres of jitter, with the distance readout recomputed from it. - await push(geoWith(47.60002, 1421.3)); - expect(sent, hasLength(2), - reason: 'a moved distanceM is indistinguishable from real news'); + await pushExpectingSend(geoWith(47.60002, 1421.3), 2); }); test('measures from the last fix the watch received', () async { - await push(geoAt(47.6, fixedAtMs: 1760000000000), wait: firstPush); - expect(sent, hasLength(1)); + await pushExpectingSend(geoAt(47.6, fixedAtMs: 1760000000000), 1); // Two ~11 m steps in the same direction. Each is short of the threshold // on its own, but the second lands ~22 m from the fix the watch actually // holds, so it goes. Anchoring on the previous *computed* fix instead // would suppress a wearer walking away one short step at a time. - await push(geoAt(47.6001, fixedAtMs: 1760000030000)); - expect(sent, hasLength(1)); - await push(geoAt(47.6002, fixedAtMs: 1760000060000)); - expect(sent, hasLength(2), reason: '~22 m from the delivered fix'); + await pushExpectingSuppression(geoAt(47.6001, fixedAtMs: 1760000030000), 1); + await pushExpectingSend(geoAt(47.6002, fixedAtMs: 1760000060000), 2); expect(sentLat(1), 47.6002); }); }); From 0b383157ac8725b7fdf78b94d3a70f3c20f15a23 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:31:55 -0700 Subject: [PATCH 54/71] Let the watch app install on watchOS 11 The three watch configurations carried a 26.0 deployment target, which gates installation on every earlier OS. Nothing in the app needs it: the compile floor is 10.0, and below that only `@Observable` and one `NodeListView` initialiser fail. watchOS 11 and 26 share a hardware floor of Series 6, so this costs no device support at all and reaches everyone who has not updated. Built, installed, and launched on watchOS 10.5, 11.5, and 26.5. Map, controls, and settings all render, including on the smallest watchOS 10 screen, because layout is measured from safe-area insets rather than tuned to one version's chrome. Also drop the Foundation.framework reference, which pointed into a WatchOS11.0.sdk path that no longer exists in Xcode 26. Swift links Foundation implicitly, so the entry was inert generator residue. --- ios/Runner.xcodeproj/project.pbxproj | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 170fd02..f34448d 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -35,7 +35,6 @@ 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 */; }; - CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */; }; 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 */; }; @@ -128,7 +127,6 @@ 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; }; - 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -155,7 +153,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - CAF9ECAD9403CAB65D2DF448 /* Foundation.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -219,7 +216,6 @@ 321ECD9614BDCAFCF545D162 /* watchOS */ = { isa = PBXGroup; children = ( - 92ADBADB6F5FE8E07CDC4600 /* Foundation.framework */, ); name = watchOS; sourceTree = ""; @@ -848,7 +844,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 26.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Profile; }; @@ -1041,7 +1037,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; VALIDATE_PRODUCT = YES; - WATCHOS_DEPLOYMENT_TARGET = 26.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Release; }; @@ -1156,7 +1152,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 26.0; + WATCHOS_DEPLOYMENT_TARGET = 11.0; }; name = Debug; }; From 0e3916d7a2d6b7a6cd67e2bae1eaf8d584d82d29 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:32:04 -0700 Subject: [PATCH 55/71] Stop a fast watch clock widening the transmit window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queued command carries the time the wrist tapped it, and the phone refuses one older than 30 seconds so a transmit cannot be attributed to a place the vehicle has already left. Only the upper bound was checked, so a timestamp in the future made the age negative and sailed through: a watch clock running ten minutes fast bought its commands ten extra minutes of life. The map-geography check directly above already had this right, and now both use the same tolerance. The window had no coverage at all, in either direction. Add it for every transmitting command: too old refuses and never reaches admission, a small forward skew still runs, and a far-future stamp refuses. Also pin the two exemptions, since both are deliberate — an untimestamped command from an older watch build is still accepted, and requestSnapshot is exempt because it transmits nothing. --- lib/services/watch/watch_bridge_service.dart | 7 +- .../watch/watch_wire_contract_test.dart | 103 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index b6ef048..2ff2c7b 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -206,7 +206,12 @@ class WatchBridgeService { } } if (kind != WatchCommandKind.requestSnapshot && issuedAtMs != null) { - if (ageMs! > _maximumCommandAge.inMilliseconds) { + // Both bounds matter. Too old is the obvious case; too far in the future + // is the same bug wearing a disguise, because a watch clock running fast + // makes `ageMs` negative and would otherwise extend the window by however + // far the clocks disagree. The tolerance matches the map-geo check above. + if (ageMs! > _maximumCommandAge.inMilliseconds || + ageMs < -_clockTolerance.inMilliseconds) { const reason = 'Took too long to reach iPhone'; // This window is about correctness, not queue housekeeping: executing // a transmit after the vehicle has moved attributes it to the wrong diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index 60cf578..d54bda6 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -866,6 +866,109 @@ void main() { expect(handled, hasLength(2)); }); + // transferUserInfo keeps a tapped command alive until the phone is + // reachable again, so the admission window is the only thing stopping a + // queued transmit from firing long after the wearer asked for it. Both + // bounds are load-bearing: "too old" is the queue sitting on it, and "too + // far in the future" is a skewed watch clock buying the same command extra + // life. requestSnapshot is deliberately exempt — it transmits nothing. + group('queued command age', () { + const transmitting = ['startSession', 'stopSession', 'manualPing']; + + double nowMs() => DateTime.now().millisecondsSinceEpoch.toDouble(); + + for (final kind in transmitting) { + test('$kind older than 30 seconds is refused', () async { + final refusals = []; + bridge.attachCommandHandler( + (command) async { + handled.add(command.kind); + return null; + }, + onRefusal: refusals.add, + ); + + final reply = await sendCommand( + 'aged-$kind', + kind, + issuedAtMs: nowMs() - const Duration(seconds: 31).inMilliseconds, + ); + + expect(reply?['accepted'], isFalse); + expect(reply?['reason'], 'Took too long to reach iPhone'); + expect(handled, isEmpty, + reason: 'an expired command must never reach admission'); + expect(refusals, ['Took too long to reach iPhone'], + reason: 'the wearer is told why the tap did nothing'); + }); + + test('$kind dated far into the future is refused', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'future-$kind', + kind, + issuedAtMs: nowMs() + const Duration(minutes: 10).inMilliseconds, + ); + + expect(reply?['accepted'], isFalse, + reason: 'a fast watch clock must not extend the 30s window'); + expect(handled, isEmpty); + }); + + test('$kind within the clock tolerance is accepted', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'skewed-$kind', + kind, + issuedAtMs: nowMs() + const Duration(seconds: 2).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'ordinary skew must not refuse a live tap'); + expect(handled, hasLength(1)); + }); + } + + test('an untimestamped command is still accepted', () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand('legacy-1', 'manualPing'); + + expect(reply?['accepted'], isTrue, + reason: 'older watch builds send no issuedAtMs'); + expect(handled, hasLength(1)); + }); + + test('an aged requestSnapshot is exempt from the transmit window', + () async { + bridge.attachCommandHandler((command) async { + handled.add(command.kind); + return null; + }); + + final reply = await sendCommand( + 'aged-snapshot', + 'requestSnapshot', + issuedAtMs: nowMs() - const Duration(minutes: 5).inMilliseconds, + ); + + expect(reply?['accepted'], isTrue, + reason: 'asking for state transmits nothing and cannot go stale'); + expect(handled, [WatchCommandKind.requestSnapshot]); + }); + }); + test('an unknown command is refused without reaching the handler', () async { bridge.attachCommandHandler((command) async { From 24370e4208dd8a2a73d2ff2dac798755d4b5abc7 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:32:13 -0700 Subject: [PATCH 56/71] Stop retained state reading as fresh on watch launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staleness was measured from when a snapshot reached the wrist. On launch the app ingests whatever application context WatchConnectivity retained, which can be hours old, and stamping arrival there presented long-dead state as current for a full 90 seconds — dimming and the age badge both said the phone was in touch when it was not. Age from the phone's own updatedAt instead, which the payload already carried and nothing read. Clamped to arrival so a phone clock running fast cannot date a snapshot into the future and extend its life, with a few seconds of slack so ordinary skew does not age a live payload early. A context already past the boundary is now stale immediately. The cue freshness check wanted the same tolerance for the same reason, so the two constants become one. --- ios/MeshMapperWatch/WatchSessionClient.swift | 41 +++++++++++++++----- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 44b0a2f..bb511ec 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -68,7 +68,10 @@ final class WatchSessionClient: NSObject { /// about "the phone has probably gone away", not "no update recently". static let staleAfter: TimeInterval = 90 private static let cueFreshFor: TimeInterval = 30 - private static let cueClockTolerance: TimeInterval = 5 + /// 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 @@ -253,16 +256,36 @@ final class WatchSessionClient: NSObject { } } - private func markSnapshotReceived(at arrival: Date = Date()) { + /// - 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() - receivedAt = arrival + 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(Self.staleAfter)) - guard !Task.isCancelled, self?.receivedAt == arrival else { return } + try? await Task.sleep(for: .seconds(remaining)) + guard !Task.isCancelled, self?.receivedAt == origin else { return } self?.isStale = true self?.staleBoundaryTask = nil } @@ -271,9 +294,9 @@ final class WatchSessionClient: NSObject { private static func isFresh(_ cue: WatchHapticCue, at arrival: Date) -> Bool { guard let issuedAt = cue.issuedAt else { return false } let age = arrival.timeIntervalSince(issuedAt) - // Phone and watch clocks normally agree, but a few seconds of skew must - // not suppress a real failure that has just crossed the radio. - return age >= -cueClockTolerance && age <= cueFreshFor + // A few seconds of skew must not suppress a real failure that has just + // crossed the radio. + return age >= -clockTolerance && age <= cueFreshFor } // MARK: - Ingest @@ -300,7 +323,7 @@ final class WatchSessionClient: NSObject { let arrival = Date() self.versionMismatch = false self.snapshot = decoded - self.markSnapshotReceived(at: arrival) + self.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. From 41949ac3fcf79b109ae05ce159c6ed4afb546c9e Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:32:21 -0700 Subject: [PATCH 57/71] Clear out two pieces of development residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app header still described Phase 2 as shipping a transport and a raw debug dump with the map, node list, and controls due later. All of them are here, so a reader had no way to tell which files were finished. The satellite preference was write-only: declared, persisted, loaded on launch, and read by nothing. Settings offers no toggle for it, and the map page documents why there is none — Apple's .imagery renders indistinguishably from .standard at this size. --- ios/MeshMapperWatch/MeshMapperWatchApp.swift | 4 ++-- ios/MeshMapperWatch/WatchSettings.swift | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index 56bf539..5e6030d 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -6,8 +6,8 @@ import SwiftUI /// 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. /// -/// Phase 2 ships the transport and a raw debug dump; the map, node list, and -/// real controls arrive in later phases. +/// 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() diff --git a/ios/MeshMapperWatch/WatchSettings.swift b/ios/MeshMapperWatch/WatchSettings.swift index f4eca88..8ab5e9d 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -10,7 +10,6 @@ import SwiftUI @Observable final class WatchSettings { private enum Key { - static let satellite = "map.satellite" static let showLinks = "map.showLinks" static let follow = "map.follow" static let mapLatitudeDelta = "map.latitudeDelta" @@ -101,7 +100,6 @@ final class WatchSettings { init(defaults: UserDefaults = .standard) { self.defaults = defaults - satellite = defaults.bool(forKey: Key.satellite) 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. @@ -134,12 +132,6 @@ final class WatchSettings { ) as? Bool ?? false } - /// Apple imagery rather than the standard basemap. Mirrors the iOS app's - /// satellite option, though the imagery is Apple's, not ArcGIS. - var satellite: Bool { - didSet { defaults.set(satellite, forKey: Key.satellite) } - } - /// Draw a line from the fix to each repeater that answered the last ping. var showLinks: Bool { didSet { defaults.set(showLinks, forKey: Key.showLinks) } From 87f6f1e3357865202a10a21a1ff4b467a969a1b2 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:32:21 -0700 Subject: [PATCH 58/71] Run the tests in CI The suite is no longer empty: 196 tests cover the watch wire contract, geo suppression, redelivery dedupe, movement gating, and command admission, and they are what protects this work from a quiet regression. The comment claiming there were no tests had outlived its truth. --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 From 5a2be4da370cd7eb8433d7721dc94e36ad0231e8 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 19:51:06 -0700 Subject: [PATCH 59/71] Answer a requested refresh even when nothing changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ageing retained state from the phone's updatedAt fixed state that lied about being fresh, and exposed the reciprocal hole: the refresh the watch sends to escape that state could be deduplicated into silence. The phone forgets its delivered-payload fingerprint only when WatchConnectivity reports a state change. A watch app relaunch is not one — `sessionReachabilityDidChange` is not implemented, and pairing and installation are unchanged, so nothing calls `publishStatus()`. The fingerprint survives, the rebuilt payload matches it once updatedAtMs is stripped, and `_flush` returns without sending. The watch then sits on a context it has correctly marked stale, with no way to prove otherwise, until something unrelated moves. It needs the map to have been visible when the watch died: a suppressed map means requestSnapshot restores geography, which changes the payload and defeats dedupe by itself. Leaving the watch on the map page is both the common case and the one where the dimming is most visible. So requestSnapshot now forces delivery. Only that command does — most immediate updates are immediate precisely because something changed, and should stay deduplicatable. The radio throttle still applies, and the obligation outlives its own deferral rather than bypassing it, so a watch asking repeatedly cannot turn this into an unmetered path to the radio. It is cleared on delivery, not on the decision to send, so a payload native refuses does not strand the wearer exactly as before. --- lib/providers/app_state_provider.dart | 8 +- lib/services/watch/watch_bridge_service.dart | 41 ++++++- .../watch/watch_wire_contract_test.dart | 108 +++++++++++++++++- 3 files changed, 152 insertions(+), 5 deletions(-) diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index 4a70f74..a4501d7 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1224,12 +1224,13 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { ); } - void _scheduleWatchSync({bool immediate = false}) { + void _scheduleWatchSync({bool immediate = false, bool forceDelivery = false}) { if (_isDisposed || !_watchBridge.canSync) return; _watchBridge.schedule( _buildWatchSnapshot, urgencyKeyBuilder: _buildWatchUrgencyKey, immediate: immediate, + forceDelivery: forceDelivery, ); } @@ -1643,7 +1644,10 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { switch (kind) { case WatchCommandKind.requestSnapshot: - _scheduleWatchSync(immediate: true); + // The watch only asks after a relaunch or a return to the foreground, + // when what it holds is a retained context of unknown age. Dedupe would + // answer an unchanged session with silence and leave it stale. + _scheduleWatchSync(immediate: true, forceDelivery: true); return null; case WatchCommandKind.startSession: diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 2ff2c7b..2bf9a6d 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -97,6 +97,20 @@ class WatchBridgeService { DateTime? _lastBuiltAt; bool _disposed = false; bool _didReconcileNativeState = false; + + /// A refresh the watch asked for, which dedupe must not answer with silence. + /// + /// The phone only forgets its delivered-payload fingerprint when + /// WatchConnectivity reports a state change, and relaunching the watch app is + /// not one: pairing and installation are unchanged, so the phone still + /// believes the watch holds this exact payload. It does — but only as a + /// retained context whose age is now shown honestly, which is precisely the + /// state the wearer is asking to be rid of. Answering "nothing changed" with + /// nothing at all leaves that state stale until something unrelated moves. + /// + /// Survives a deferred flush so the radio throttle can still delay the + /// refresh, and is cleared only once a payload is actually delivered. + bool _forceDelivery = false; bool _canSync = false; Map? _lastNativeStatus; DateTime? _lastSuccessfulSendAt; @@ -334,15 +348,21 @@ class WatchBridgeService { } } + /// - Parameter forceDelivery: send even when the payload is byte-identical to + /// the last delivered one. Reserved for a refresh the watch explicitly + /// asked for; ordinary immediate updates stay deduplicatable, because most + /// of them are urgent precisely because something did change. void schedule( WatchSnapshotBuilder snapshotBuilder, { required WatchUrgencyKeyBuilder urgencyKeyBuilder, bool immediate = false, + bool forceDelivery = false, }) { if (_disposed || !canSync) return; _pendingSnapshotBuilder = snapshotBuilder; _pendingUrgencyKeyBuilder = urgencyKeyBuilder; + if (forceDelivery) _forceDelivery = true; _scheduledUpdate?.cancel(); if (immediate) { @@ -401,7 +421,11 @@ class WatchBridgeService { final fingerprint = Map.from(payload) ..remove('updatedAtMs'); final encoded = jsonEncode(fingerprint); - if (encoded == _lastPayload) return; + // A forced refresh is answered with the payload as it stands, identical or + // not. Only the fresher updatedAt distinguishes it, and that is the whole + // point: it is what proves the phone is still there. + final force = _forceDelivery; + if (!force && encoded == _lastPayload) return; // The movement gate. A stationary GPS jitters by a few metres for as long // as the phone is switched on, and forwarding that would keep the watch @@ -415,8 +439,16 @@ class WatchBridgeService { // visibly so once the watch's zoom floor reached ~22 m of latitude. final fix = _fixOf(fingerprint); final withoutFix = _encodeWithoutFix(fingerprint); - if (withoutFix == _lastPayloadWithoutFix && !_fixMovedEnough(fix)) return; + if (!force && + withoutFix == _lastPayloadWithoutFix && + !_fixMovedEnough(fix)) { + return; + } + // The throttle still applies. It delays a forced refresh by at most the + // non-urgent interval and `_forceDelivery` outlives the deferral, so the + // refresh still arrives — while a watch asking repeatedly cannot turn this + // into an unmetered path to the radio. final urgent = snapshot.urgencyKey != _lastUrgencyKey; final sentAt = _lastSentAt; if (!urgent && sentAt != null) { @@ -446,6 +478,11 @@ class WatchBridgeService { return; } _didReconcileNativeState = true; + // Cleared here rather than at the dedupe check: a send the native side + // refused leaves the fingerprint in place, so an obligation dropped + // earlier would dedupe against that same payload and strand the wearer + // exactly as before. + _forceDelivery = false; _lastPayload = encoded; // Anchored to what the watch actually received, so a refused or dropped // send cannot quietly consume the wearer's next 15 m of movement. diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index d54bda6..ea22346 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -21,6 +21,7 @@ WatchSnapshot _snapshot({ WatchGeo? geo, bool mapGeoIncluded = true, List availableStartModes = const [WatchStartMode.passive], + DateTime? updatedAt, }) => WatchSnapshot( core: LiveActivitySnapshot( @@ -59,7 +60,7 @@ WatchSnapshot _snapshot({ pingColor: const WatchColor(1, 0, 0), phaseDurationMs: phaseDurationMs, cue: cue, - updatedAt: DateTime.fromMillisecondsSinceEpoch(1759999999000), + updatedAt: updatedAt ?? DateTime.fromMillisecondsSinceEpoch(1759999999000), ); void main() { @@ -720,6 +721,111 @@ void main() { reason: 'native cleared its context cache on the state change'); }); + // The watch asks for a snapshot after a relaunch, holding a retained + // context it now ages honestly. The phone forgets its delivered fingerprint + // only on a WatchConnectivity state change, and a watch app restart is not + // one — pairing and installation never changed. Without a force path an + // unchanged session answers that request with silence, and the wearer keeps + // looking at state marked stale while the phone is alive and listening. + group('a requested refresh defeats dedupe', () { + // Long enough to clear the non-urgent radio interval. A forced refresh is + // deliberately still subject to it, so anything shorter would only prove + // the throttle deferred the flush, not what the flush decided. + const settle = Duration(milliseconds: 2300); + + Future deliver({ + DateTime? updatedAt, + bool forceDelivery = false, + Duration wait = settle, + }) async { + bridge.schedule( + () => _snapshot(updatedAt: updatedAt), + urgencyKeyBuilder: () => _snapshot(updatedAt: updatedAt).urgencyKey, + immediate: true, + forceDelivery: forceDelivery, + ); + await Future.delayed(wait); + } + + test('an identical payload is still deduplicated without one', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + ); + + expect(syncCalls, 1, + reason: 'a newer updatedAt alone must not spend the radio'); + }); + + test('a forced refresh sends the unchanged payload again', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + // Semantically identical to what the watch already holds. Only + // updatedAt moved, which is exactly what proves the phone is alive. + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + + expect(syncCalls, 2, + reason: 'a refresh the watch asked for must reach it'); + }); + + test('the throttle delays a forced refresh but cannot cancel it', + () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + // The first delivery is unthrottled — nothing has been built yet — so + // the forced one has to follow it closely enough to land inside the + // interval it opens. + await deliver(wait: const Duration(milliseconds: 10)); + expect(syncCalls, 1); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + wait: const Duration(milliseconds: 10), + ); + + expect(syncCalls, 1, + reason: 'the radio interval still governs when it goes out'); + + await Future.delayed(settle); + + expect(syncCalls, 2, + reason: 'the obligation must survive its own deferral'); + }); + + test('the obligation is spent, not standing', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + expect(syncCalls, 2); + + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000700000), + ); + + expect(syncCalls, 2, + reason: 'one request buys one delivery, not a permanent bypass'); + }); + }); + test('the nonurgent throttle runs before the snapshot builder', () async { bridge.attachCommandHandler((_) => null); await Future.delayed(Duration.zero); From 2bfe0e2940e4e9498452a12120e00626df8fd6e3 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 20:16:30 -0700 Subject: [PATCH 60/71] Stop map-geo lease renewals forcing a snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requestSnapshot carries two intents down one wire. One is a genuine plea for state, after a relaunch or a resume onto a retained context of unknown age. The other only changes what future snapshots contain: the map-geo lease, which renews as the same command every five minutes for as long as the map stays hidden. Forcing delivery for the command kind therefore forced an otherwise identical snapshot on every renewal — spending the radio exactly where the lease exists to save it, and against the comment saying renewal "keeps a long Always-On session cheap". Carry the intent explicitly instead. It is not inferred from mapGeoNeeded, which the bridge may legitimately resolve to nil when a suppression claim arrives stale or out of order, and which is also true for an ordinary return to the map — a transition that already defeats dedupe by restoring geography and needs no help. Absent on the wire means false, so a phone paired with an older watch build behaves as it did before. Also pin the behaviour the force path leans on hardest: a send native refuses leaves the fingerprint untouched, so the obligation must survive it or the next flush dedupes against that same payload and strands the wearer exactly as before the fix. --- ios/MeshMapperWatch/WatchSessionClient.swift | 61 +++++++++++++- ios/Shared/MeshMapperWatchPayload.swift | 14 ++++ lib/providers/app_state_provider.dart | 14 +++- lib/services/watch/watch_bridge_service.dart | 1 + lib/services/watch/watch_models.dart | 14 +++- .../watch/watch_wire_contract_test.dart | 79 +++++++++++++++++++ 6 files changed, 175 insertions(+), 8 deletions(-) diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index bb511ec..bb4bd96 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -103,6 +103,42 @@ final class WatchSessionClient: NSObject { 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 + } + + ingest(context: session.receivedApplicationContext) + + if snapshot == nil || isStale { + requestFullSnapshot() + } + + if !mapGeoNeeded { scheduleMapGeoSuppression() } + } + private var pendingRefresh = false // MARK: - Commands @@ -119,6 +155,7 @@ final class WatchSessionClient: NSObject { _ kind: WatchCommand.Kind, mode: String? = nil, mapGeoNeeded: Bool? = nil, + forceRefresh: Bool = false, silent: Bool = false ) { guard let session, session.activationState == .activated else { @@ -132,6 +169,7 @@ final class WatchSessionClient: NSObject { kind: kind, mode: mode, mapGeoNeeded: mapGeoNeeded, + forceRefresh: forceRefresh ? true : nil, id: UUID().uuidString, issuedAtMs: Date().timeIntervalSince1970 * 1000 ) @@ -194,17 +232,34 @@ final class WatchSessionClient: NSObject { } } - private func sendMapGeoPreference(_ needed: Bool, force: Bool = false) { + /// - 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, silent: true) + 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. - sendMapGeoPreference(true, force: true) + // + // 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) { diff --git a/ios/Shared/MeshMapperWatchPayload.swift b/ios/Shared/MeshMapperWatchPayload.swift index a61f440..98a3bde 100644 --- a/ios/Shared/MeshMapperWatchPayload.swift +++ b/ios/Shared/MeshMapperWatchPayload.swift @@ -375,6 +375,20 @@ struct WatchCommand: Codable, Hashable { /// 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. diff --git a/lib/providers/app_state_provider.dart b/lib/providers/app_state_provider.dart index a4501d7..185fbfd 100644 --- a/lib/providers/app_state_provider.dart +++ b/lib/providers/app_state_provider.dart @@ -1644,10 +1644,16 @@ class AppStateProvider extends ChangeNotifier with WidgetsBindingObserver { switch (kind) { case WatchCommandKind.requestSnapshot: - // The watch only asks after a relaunch or a return to the foreground, - // when what it holds is a retained context of unknown age. Dedupe would - // answer an unchanged session with silence and leave it stale. - _scheduleWatchSync(immediate: true, forceDelivery: true); + // 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: diff --git a/lib/services/watch/watch_bridge_service.dart b/lib/services/watch/watch_bridge_service.dart index 2bf9a6d..4d0ed6d 100644 --- a/lib/services/watch/watch_bridge_service.dart +++ b/lib/services/watch/watch_bridge_service.dart @@ -244,6 +244,7 @@ class WatchBridgeService { kind: kind, mode: args['mode'] as String?, mapGeoNeeded: effectiveMapGeoNeeded, + forceRefresh: args['forceRefresh'] == true, )); final refusal = admission is Future ? await admission : admission; diff --git a/lib/services/watch/watch_models.dart b/lib/services/watch/watch_models.dart index 9d05b28..fc63c51 100644 --- a/lib/services/watch/watch_models.dart +++ b/lib/services/watch/watch_models.dart @@ -459,7 +459,12 @@ enum WatchCommandKind { /// Decoded wrist intent. [mode] stays raw until phone-side admission so an /// unknown value can be refused rather than mistaken for an omitted mode. class WatchCommand { - const WatchCommand({required this.kind, this.mode, this.mapGeoNeeded}); + const WatchCommand({ + required this.kind, + this.mode, + this.mapGeoNeeded, + this.forceRefresh = false, + }); final WatchCommandKind kind; final String? mode; @@ -468,6 +473,13 @@ class WatchCommand { /// keep the fail-safe full payload; new phones suppress only after a fresh /// false claim. final bool? mapGeoNeeded; + + /// Whether the wrist is asking for state, rather than changing what future + /// snapshots contain. Only the former may defeat unchanged-state dedupe; + /// map-geo lease renewals travel as the same command every five minutes and + /// must stay deduplicatable. Absent on the wire means false, so an older + /// watch build behaves as it always did. + final bool forceRefresh; } typedef WatchRequestedStartModeResolution = ({ diff --git a/test/services/watch/watch_wire_contract_test.dart b/test/services/watch/watch_wire_contract_test.dart index ea22346..93a87fc 100644 --- a/test/services/watch/watch_wire_contract_test.dart +++ b/test/services/watch/watch_wire_contract_test.dart @@ -559,6 +559,7 @@ void main() { String? mode, bool? mapGeoNeeded, double? issuedAtMs, + bool? forceRefresh, }) async { final result = await TestDefaultBinaryMessengerBinding .instance.defaultBinaryMessenger @@ -571,6 +572,7 @@ void main() { if (mode != null) 'mode': mode, if (mapGeoNeeded != null) 'mapGeoNeeded': mapGeoNeeded, if (issuedAtMs != null) 'issuedAtMs': issuedAtMs, + if (forceRefresh != null) 'forceRefresh': forceRefresh, }), ), null, @@ -806,6 +808,34 @@ void main() { reason: 'the obligation must survive its own deferral'); }); + test('a native refusal does not consume the obligation', () async { + bridge.attachCommandHandler((_) => null); + await Future.delayed(Duration.zero); + + await deliver(); + expect(syncCalls, 1); + + // Native refuses. The fingerprint is deliberately not updated, so an + // obligation dropped here would dedupe against that same payload on + // every later flush and strand the watch exactly as before the fix. + syncSucceeds = false; + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000600000), + forceDelivery: true, + ); + expect(syncCalls, 2, reason: 'the refused attempt still reached native'); + + syncSucceeds = true; + // Note the absent forceDelivery: the obligation has to be the thing + // carrying this through, not a fresh request. + await deliver( + updatedAt: DateTime.fromMillisecondsSinceEpoch(1760000700000), + ); + + expect(syncCalls, 3, + reason: 'a refusal must leave the refresh still owed'); + }); + test('the obligation is spent, not standing', () async { bridge.attachCommandHandler((_) => null); await Future.delayed(Duration.zero); @@ -1075,6 +1105,55 @@ void main() { }); }); + // requestSnapshot carries two intents down one wire. Only a genuine plea + // for state may defeat dedupe; the map-geo lease renews as the same command + // every five minutes while the map stays hidden, and forcing those would + // spend the radio exactly where the lease exists to save it. The intent is + // stated rather than inferred from mapGeoNeeded, which the bridge may + // resolve to null when a suppression claim is stale or out of order. + group('refresh intent is explicit', () { + late List received; + + setUp(() { + received = []; + bridge.attachCommandHandler((command) { + received.add(command); + return null; + }); + }); + + test('a stated refresh asks for delivery', () async { + await sendCommand('r-1', 'requestSnapshot', + mapGeoNeeded: true, forceRefresh: true); + + expect(received.single.forceRefresh, isTrue); + }); + + test('a lease renewal does not', () async { + await sendCommand('r-2', 'requestSnapshot', + mapGeoNeeded: false, forceRefresh: false); + + expect(received.single.forceRefresh, isFalse, + reason: 'renewals repeat every five minutes and must stay cheap'); + }); + + test('returning to the map does not force on its own', () async { + // Restoring geography changes the payload by itself, so dedupe is + // already defeated where it matters. Forcing here would only add a + // guaranteed send to a page transition the wearer makes constantly. + await sendCommand('r-3', 'requestSnapshot', mapGeoNeeded: true); + + expect(received.single.forceRefresh, isFalse); + }); + + test('an older watch build without the field never forces', () async { + await sendCommand('r-4', 'requestSnapshot'); + + expect(received.single.forceRefresh, isFalse, + reason: 'absent must mean false, not "assume the expensive thing"'); + }); + }); + test('an unknown command is refused without reaching the handler', () async { bridge.attachCommandHandler((command) async { From d9cf5092932d1a8f2bb0ab84f243ce58c6d754f5 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 14 Aug 2026 20:16:40 -0700 Subject: [PATCH 61/71] Reconcile on resume, not only on first appearance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onAppear` fires once and cannot speak for a resume, and watchOS suspends this app for essentially the whole wrist-down interval — measured here at 7.42 s of suspension against 8.20 s of wrist-down, 12.89 against 13.63. Nothing else asked the phone for anything. So the dominant interaction on this device, raising a wrist, could land on state the UI itself declares stale while the phone was available and willing the entire time. Observe scenePhase and reconcile when the scene becomes active. This is deliberately not `refresh()`, which always requests and would put a WatchConnectivity round trip behind every glance — the opposite of what this transport is for. Ingesting the retained application context is free, so it happens on every resume; the radio is spent only when what we hold is stale or missing, which is exactly when a request can change what the wearer sees. A glance onto fresh state costs nothing, and the worst case becomes roughly one request per stale interval while someone is actually looking at the watch, rather than one per raise. The decision is logged to the existing wake-timing harness, because it is otherwise invisible: a run of wrist raises should show `resume-local` while the state is fresh and exactly one `resume-request` on the first glance past the stale boundary. That is the claim, and on hardware it is the only way to see whether it holds. --- ios/MeshMapperWatch/MeshMapperWatchApp.swift | 10 ++++++++++ ios/MeshMapperWatch/WatchSessionClient.swift | 13 ++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index 5e6030d..48754fa 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -12,6 +12,7 @@ import SwiftUI struct MeshMapperWatchApp: App { @State private var client = WatchSessionClient() @State private var settings = WatchSettings() + @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { @@ -19,6 +20,15 @@ struct MeshMapperWatchApp: App { .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/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index bb4bd96..0937719 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -132,9 +132,16 @@ final class WatchSessionClient: NSObject { ingest(context: session.receivedApplicationContext) - if snapshot == nil || isStale { - requestFullSnapshot() - } + // 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() } } From 6bce3aef28a1d9f41322b376a45547bbc17cd8e7 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 15 Aug 2026 21:55:18 -0700 Subject: [PATCH 62/71] Stop both countdown bars animating against the system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watch bar animated frame(width:) from its current fraction to zero for the length of a whole phase, so SwiftUI re-ran layout for the panel on every frame of a 60-second drain instead of handing one transform to the render server. It is now a leading-anchored scale, and it stops entirely under reduced luminance — Apple's Always-On guidance, and newly load-bearing since the dim hold keeps this panel on screen for up to 20 s past a dim, which is exactly when it was animating over a screen that updates once a minute. The scale applies to a Rectangle mask rather than to the capsule. Scaling the capsule squashed its end caps, so the bar started rounded and finished square. A comment here previously excused that as "the shape it was already collapsing to anyway"; Adam saw it within a minute of getting the build on his wrist. The Live Activity bar was asking ActivityKit for something it does not do: a @State fraction driven by a withAnimation lasting the entire phase. Widgets cap a custom animation at two seconds and run none at all under reduced luminance, so on an always-on lock screen the fill stayed where the last update left it — "mostly full at 9 seconds remaining". Every @State, Task and withAnimation is gone. The title and native countdown share a row and a ProgressView(timerInterval:) draws beneath, so both are derived from absolute dates and every render the system chooses to make lands correctly. The bar now fills rather than drains, which is the trade for handing the work over: its progress comes from a date range and cannot be inverted. Its range is the whole phase, never now...deadline, which would reset it on every redraw. Also here, and confirmed on hardware after one failed attempt: the drain no longer outlives the phase. Assigning a value it already holds does not end the animation driving it — withAnimation sets the model value immediately and animates only the presentation, so mid-drain remainingFraction is already 0, and the snap-to-truth on a stopped session assigned 0 to 0 and changed nothing. The bar kept draining to the old deadline while the title read "Ready". A dim never showed this because a dim mid-phase has a non-zero truth. The nudge that forces a real change needs its own transaction, because SwiftUI batches state changes made in one run-loop turn and the first attempt coalesced into a single net-zero change. Non-urgent Live Activity updates now wait 15 s rather than 2. Phase changes, ping outcomes, connection loss and zone changes stay in urgencyKey and still go immediately; counter and repeater churn waits. This saves on both devices, since a locally generated ActivityKit update is mirrored to the paired watch and counts against its Live Activity budget. The bar probe logs phase title, deadline, duration, fraction, lapsed and whether the fill drains, on every re-run of the phase task. It is what separated "the task never re-ran" from "the animation outlived the state", after this one line had already been wrong three different ways in a day. --- .../MeshMapperLiveActivity.swift | 228 +++++++----------- ios/MeshMapperWatch/MapPage.swift | 197 ++++++++++----- .../live_activity/live_activity_service.dart | 26 +- .../live_activity_service_test.dart | 51 +++- 4 files changed, 308 insertions(+), 194 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index ca4bb43..1a91220 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -108,7 +108,6 @@ private struct MeshMapperLockScreenContent: View { VStack(alignment: .leading, spacing: 9) { MeshMapperPhaseBar( state: state, - height: 20, titleFont: .subheadline.weight(.semibold), countdownFont: .subheadline.monospacedDigit().weight(.semibold), countdownWidth: 54 @@ -147,7 +146,6 @@ private struct MeshMapperSmallActivityContent: View { VStack(alignment: .leading, spacing: 7) { MeshMapperPhaseBar( state: state, - height: 18, titleFont: .caption.weight(.semibold), countdownFont: .caption2.monospacedDigit().weight(.bold), countdownWidth: 40 @@ -187,133 +185,79 @@ private struct MeshMapperSmallActivityContent: View { } } -/// The phase as a locally depleting bar, matching the watch map panel. +/// The phase as a caption, a native countdown, and system-drawn progress. /// -/// Absolute deadline plus duration lets SwiftUI animate between sparse phone -/// updates. The title and countdown ride over the fill so neither consumes -/// track width, and their shadow keeps them legible on both halves. +/// **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 { - @Environment(\.isLuminanceReduced) private var isLuminanceReduced - let state: MeshMapperActivityAttributes.ContentState - let height: CGFloat let titleFont: Font let countdownFont: Font let countdownWidth: CGFloat - @State private var remainingFraction: CGFloat - @State private var deadlineLapsed: Bool - - init( - state: MeshMapperActivityAttributes.ContentState, - height: CGFloat, - titleFont: Font, - countdownFont: Font, - countdownWidth: CGFloat - ) { - self.state = state - self.height = height - self.titleFont = titleFont - self.countdownFont = countdownFont - self.countdownWidth = countdownWidth - - let now = Date() - _remainingFraction = State( - initialValue: state.phaseRemainingFraction(at: now) ?? 0 - ) - _deadlineLapsed = State( - initialValue: state.phaseEndsAt.map { $0 <= now } ?? false - ) - } - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - if !isLuminanceReduced { - Capsule() - // Progress says how much time remains. Outcome has quieter, - // dedicated dots elsewhere and must not recolour the whole track. - .fill(MeshMapperPalette.accent) - .frame(width: geometry.size.width * remainingFraction) - } - } - .overlay { - HStack { - Text(state.phaseTitle) - .font(titleFont) - .foregroundStyle(.white.opacity(deadlineLapsed ? 0.45 : 1)) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 4) - MeshMapperCountdown( - state: state, - isActive: !deadlineLapsed, - font: countdownFont - ) + 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) - } - // The native timer keeps a small amount of reserved space beyond the - // visible glyphs even inside its trailing-aligned fixed frame. Leaving - // the title at seven points but tucking that reservation toward the - // rounded cap puts the visible digits about four points from the end; - // 2.5 points still clears the curve on the shortest 18 pt track. - .padding(.leading, 7) - .padding(.trailing, 2.5) - .shadow(color: .black.opacity(0.7), radius: 1.5) } - } - .frame(height: height) - // An element that implies continuous motion must not be drawn where the - // refresh rate cannot deliver it. Luminance participates only to stop and - // resume rendering; among payload fields, deadline and duration alone - // identify the drain, so a caption change cannot restart it. - .task(id: animationTaskKey) { - await runPhaseAnimation(animateFill: !isLuminanceReduced) + MeshMapperPhaseProgress(state: state) } } +} - private var animationTaskKey: MeshMapperPhaseAnimationTaskKey { - MeshMapperPhaseAnimationTaskKey( - drain: state.phaseAnimationKey, - canRenderContinuously: !isLuminanceReduced - ) - } - - @MainActor - private func runPhaseAnimation(animateFill: Bool) async { - let now = Date() - let fraction = state.phaseRemainingFraction(at: now) ?? 0 - let lapsed = state.phaseEndsAt.map { $0 <= now } ?? false - - // ActivityKit may replace state midway through a phase. Snap to the - // absolute fraction before starting one compositor animation, so no timer - // tick or stale previous endpoint can distort the new bar. - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - remainingFraction = fraction - deadlineLapsed = lapsed - } - - guard animateFill else { return } - guard let endsAt = state.phaseEndsAt else { return } - let remaining = endsAt.timeIntervalSince(now) - guard remaining > 0 else { return } - - await Task.yield() - withAnimation(.linear(duration: remaining)) { - remainingFraction = 0 - } +/// 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 - do { - try await Task.sleep(for: .seconds(remaining)) - } catch { - return + 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) } - guard !Task.isCancelled else { return } - deadlineLapsed = true } + + /// Matches the linear `ProgressView` track this stands in for. + private static let trackHeight: CGFloat = 4 } private struct MeshMapperStatusLabel: View { @@ -438,7 +382,6 @@ private struct MeshMapperIslandBottom: View { VStack(alignment: .leading, spacing: 6) { MeshMapperPhaseBar( state: state, - height: 18, titleFont: .caption.weight(.semibold), countdownFont: .caption2.monospacedDigit().weight(.bold), countdownWidth: 42 @@ -532,7 +475,7 @@ private struct MeshMapperCompactTrailing: View { MeshMapperOutcomeDot(state: state, diameter: 8) } } - .task(id: state.phaseAnimationKey) { + .task(id: state.phaseDeadlineKey) { let now = Date() deadlineLapsed = state.phaseEndsAt.map { $0 <= now } ?? false guard let endsAt = state.phaseEndsAt else { return } @@ -551,7 +494,7 @@ private struct MeshMapperCompactTrailing: View { private struct MeshMapperCountdown: View { let state: MeshMapperActivityAttributes.ContentState - let isActive: Bool + var isActive: Bool = true let font: Font var body: some View { @@ -569,19 +512,14 @@ private enum MeshMapperPalette { static let accent = Color.accentColor } -private struct MeshMapperPhaseAnimationKey: Hashable { +private struct MeshMapperPhaseDeadlineKey: Hashable { let endsAt: Date? let durationMs: Int? } -private struct MeshMapperPhaseAnimationTaskKey: Hashable { - let drain: MeshMapperPhaseAnimationKey - let canRenderContinuously: Bool -} - extension MeshMapperActivityAttributes.ContentState { - fileprivate var phaseAnimationKey: MeshMapperPhaseAnimationKey { - MeshMapperPhaseAnimationKey( + fileprivate var phaseDeadlineKey: MeshMapperPhaseDeadlineKey { + MeshMapperPhaseDeadlineKey( endsAt: phaseEndsAt, durationMs: phaseDurationMs ) @@ -593,6 +531,28 @@ extension MeshMapperActivityAttributes.ContentState { 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" } @@ -660,17 +620,6 @@ extension MeshMapperActivityAttributes.ContentState { default: return .white } } - - /// Fraction remaining in the current countdown, calculated locally so the - /// Live Activity does not need a state update every second. - fileprivate func phaseRemainingFraction(at date: Date) -> CGFloat? { - guard let phaseEndsAt, let phaseDurationMs, phaseDurationMs > 0 else { - return nil - } - let remaining = phaseEndsAt.timeIntervalSince(date) - guard remaining > 0 else { return 0 } - return CGFloat(min(1, remaining / (Double(phaseDurationMs) / 1000))) - } } extension MeshMapperActivityAttributes.HeardRepeater { @@ -825,6 +774,15 @@ private struct MeshMapperActivityPreview: View { /// 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 diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 80702e6..49fb015 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -1269,8 +1269,8 @@ struct MapPage: View { .foregroundStyle(.orange) .lineLimit(1) } else if let snapshot { - WatchPhaseBar(snapshot: snapshot) - .frame(height: 15) + WatchPhaseBar(snapshot: snapshot, isLuminanceReduced: isLuminanceReduced) + .frame(height: 15) } } @@ -1701,17 +1701,22 @@ struct MapPage: View { /// 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 animated to zero by the -/// compositor; one sleeping task wakes at the deadline solely to retire the -/// countdown and dim a claim the phone has not refreshed. +/// 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) { + init(snapshot: WatchSnapshot, isLuminanceReduced: Bool) { self.snapshot = snapshot + self.isLuminanceReduced = isLuminanceReduced let now = Date() _remainingFraction = State( initialValue: CGFloat(snapshot.phaseRemainingFraction(at: now) ?? 0) @@ -1721,10 +1726,29 @@ private struct WatchPhaseBar: View { ) } + /// 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 + durationMs: snapshot.phaseDurationMs, + drainsFill: drainsFill ) } @@ -1739,37 +1763,50 @@ private struct WatchPhaseBar: View { } var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(.white.opacity(0.16)) - Capsule() - .fill(snapshot.pingColor.map(Color.init) ?? .accentColor) - .frame(width: geo.size.width * remainingFraction) - } - .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) - } + 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) } + // 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() @@ -1783,34 +1820,82 @@ private struct WatchPhaseBar: View { 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. - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { + // 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 } - await Task.yield() - #if DEBUG - // A/B harness for the drain's energy cost. The fill is an animated *layout* - // width, so SwiftUI re-runs layout every frame for the whole phase rather - // than handing a transform to the render server. Freezing it leaves every - // other cost in place — same snapshots, same markers, same timer text — so - // an Instruments trace of the two states isolates this animation alone. - if !WatchSettings.debugFreezesTimerBar { - withAnimation(.linear(duration: remaining)) { remainingFraction = 0 } - } - #else - withAnimation(.linear(duration: remaining)) { - remainingFraction = 0 + if drainsFill { + await Task.yield() + withAnimation(.linear(duration: remaining)) { + remainingFraction = 0 + } } - #endif + // 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 { @@ -1826,6 +1911,10 @@ private struct WatchPhaseBar: View { // 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 } } diff --git a/lib/services/live_activity/live_activity_service.dart b/lib/services/live_activity/live_activity_service.dart index e1e85f3..f41dc6f 100644 --- a/lib/services/live_activity/live_activity_service.dart +++ b/lib/services/live_activity/live_activity_service.dart @@ -18,14 +18,36 @@ class LiveActivityService { @visibleForTesting MethodChannel? channel, @visibleForTesting Duration unavailableRetryDelay = const Duration(seconds: 30), + @visibleForTesting + Duration minimumNonUrgentInterval = defaultMinimumNonUrgentInterval, }) : _channel = channel ?? const MethodChannel('meshmapper/live_activity'), - _unavailableRetryDelay = unavailableRetryDelay; + _unavailableRetryDelay = unavailableRetryDelay, + _minimumNonUrgentInterval = minimumNonUrgentInterval; static const Duration _debounceDelay = Duration(milliseconds: 200); - static const Duration _minimumNonUrgentInterval = Duration(seconds: 2); + + /// 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; diff --git a/test/services/live_activity/live_activity_service_test.dart b/test/services/live_activity/live_activity_service_test.dart index cca49bb..a27d2e0 100644 --- a/test/services/live_activity/live_activity_service_test.dart +++ b/test/services/live_activity/live_activity_service_test.dart @@ -4,14 +4,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mesh_mapper/services/live_activity/live_activity_models.dart'; import 'package:mesh_mapper/services/live_activity/live_activity_service.dart'; -LiveActivitySnapshot _snapshot() => LiveActivitySnapshot( +LiveActivitySnapshot _snapshot({ + LiveActivityPhase phase = LiveActivityPhase.listeningDiscovery, + int rxCount = 2, +}) => + LiveActivitySnapshot( sessionId: 'session-1', mode: 'Passive', - phase: LiveActivityPhase.listeningDiscovery, + phase: phase, phaseTitle: 'Listening…', isConnected: true, txCount: 0, - rxCount: 2, + rxCount: rxCount, discoveryCount: 1, traceCount: 0, queueSize: 0, @@ -51,4 +55,45 @@ void main() { expect(syncCalls, 2, reason: 'enabling the feature must not require a new session ID'); }); + + test('counter churn waits out the non-urgent interval', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const channel = MethodChannel('meshmapper/live_activity_throttle_test'); + final phases = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method != 'sync') return null; + phases.add((call.arguments as Map)['phase'] as String); + return true; + }); + final service = LiveActivityService( + channel: channel, + minimumNonUrgentInterval: const Duration(seconds: 30), + ); + addTearDown(() { + service.dispose(); + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + service.schedule(_snapshot, immediate: true); + await Future.delayed(const Duration(milliseconds: 20)); + + // A changed RX count is real, but nobody is waiting on it. + service.schedule(() => _snapshot(rxCount: 3), immediate: true); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(phases, ['listening_discovery'], + reason: 'a counter change alone must not reach ActivityKit'); + + // A phase change is news, and news is not throttled. + service.schedule( + () => _snapshot(phase: LiveActivityPhase.waitingDiscovery, rxCount: 3), + immediate: true, + ); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(phases, ['listening_discovery', 'waiting_discovery']); + }); } From 50a157f1fd38ce81d5972f6bd08ba67876232063 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 15 Aug 2026 21:56:06 -0700 Subject: [PATCH 63/71] Stop the status panel dragging the camera when it resizes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adam reported the map jumping at the end of the wait and listening timers. A DEBUG probe, gated to a 2.5 s window after a phase change so a walk costs a handful of lines rather than hundreds, named the cause in one session: the panel's height. panelCameraInset feeds .safeAreaPadding(.bottom,), so every height the panel passes through was a camera reassignment. Measured across one session stop and restart, 54 -> 59 -> 40 -> 54 inside three seconds, four applyRegion calls, none animated, while the fix itself moved 0.2 m. Ordinary phase boundaries did not move the panel at all — it held at 54.0 through four of them — so this is a session-lifecycle event rather than a per-cycle one. Zoom decides whether it is visible: at the 40 m span Adam happened to be holding, a 19 pt inset change moves the framing centre 1.6 m and cannot be seen, which is why an hour of stationary watching found nothing. At the 250 m default zoom the same swing is roughly 9 m. An inset change is also an apparent zoom, not merely a pan. applyRegion holds the span constant while the safe-area padding changes the height of the band MapKit fits that span into, and fewer points for the same span is a larger scale. Nothing had said so before, and it is what made two rejected attempts read badly: settling with growth adopted immediately gave a zoom in followed by a zoom out 450 ms later, and settling symmetrically with an eased reframe still showed one step each time the panel populated or emptied. So the camera frames against the panel's high-water mark, which was Adam's suggestion: treat the panel as though it were always at its maximum. A shrink then moves the camera not at all, which is the common case and the one he saw. Growth still waits 450 ms for the height to stop moving and then eases, so a transition costs at most one gentle adjustment. Confirmed on the wrist: "no movement on start or stop." The trade is that while the panel is shorter than its maximum the map is framed as if it were not, so the puck sits above the true centre of the visible band by half the height difference, under about 10 pt at the sizes measured. A puck slightly off centre that never moves beats a centred one that jumps. The mark is per-launch @State: it survives the map subtree's teardown on a wrist raise, so it holds across glances, while a fresh launch re-derives it rather than inheriting a measurement from a content shape that has since changed. The 0.75-of-display clamp still bounds it. The settle is deliberately fail-safe rather than timer-dependent, since watchOS suspends this app and the sleep fires whenever it is next resumed — the trap the dim hold was bitten by twice. A late fire only reframes late, and the map subtree's onAppear adopts the measured height outright, so no wrist raise can find the camera framing against a height the panel has abandoned. The reframe itself is untouched. It is the only thing keeping the camera on the visible band, and this file has two scars from deleting an apparently redundant camera assignment; only which height it follows has changed. --- ios/MeshMapperWatch/MapPage.swift | 254 +++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 6 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 49fb015..b1761b6 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -250,9 +250,91 @@ struct MapPage: View { } } + #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 @@ -287,15 +369,80 @@ struct MapPage: View { /// 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 panelHeight > 0 else { return 0 } + 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, panelHeight + gapBeneath), limit) + 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 @@ -909,10 +1056,36 @@ struct MapPage: View { } .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 - recenterIfFollowing() + // 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)" }) { _, _ in + .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 recenterIfFollowing() // 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. @@ -940,10 +1113,20 @@ struct MapPage: View { // -> 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() 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 @@ -963,9 +1146,49 @@ struct MapPage: View { // 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 @@ -1446,7 +1669,11 @@ struct MapPage: View { // MARK: - Camera - private func recenterIfFollowing(force: Bool = false) { + /// `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 @@ -1469,7 +1696,7 @@ struct MapPage: View { // wearer what their action changed. Automatic follow is different: the fix // coordinate has already changed in this frame, and animating the camera // after it makes the puck wander before the map catches up. - applyRegion(center: fix, animated: force) + applyRegion(center: fix, animated: animated ?? force) } /// Guarantee this native map has a region of ours, whatever `recenterIfFollowing` @@ -1527,6 +1754,21 @@ struct MapPage: View { 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) From 675fa119ea97ce7efa69ee4ae15f0d25fc34c247 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 11:38:22 -0700 Subject: [PATCH 64/71] Let a Smart Stack tap reach the watch app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Live Activity already renders a custom .small family, so it appears in the watch's Smart Stack — but tapping it offered to open the iPhone app, because watchOS only looks for a companion when the watch target declares WKSupportsLiveActivityLaunchAttributeTypes. An empty array covers every activity type we start. watchOS 11.1+; older systems ignore an unrecognised key, so it is safe at our 11.0 floor. Note the spelling: the WWDC session names this "Supports Launch for Live Activity Attribute Types", which several write-ups render as WKSupportsLaunchForLiveActivityAttributeTypes. That key does not exist. Also drops the @State and sleeping Task from MeshMapperCompactTrailing. WidgetKit renders these views out of process and the extension "is not continually active", so a flag retired by Task.sleep is a promise this context cannot keep — the rule MeshMapperPhaseBar states two structs above. Nothing changes behaviourally: activeCountdownRange is evaluated on every render and already returns nil past the deadline. --- .../MeshMapperLiveActivity.swift | 75 ++++++------------- ios/MeshMapperWatch/Info.plist | 8 ++ 2 files changed, 31 insertions(+), 52 deletions(-) diff --git a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift index 1a91220..14f3731 100644 --- a/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift +++ b/ios/MeshMapperLiveActivity/MeshMapperLiveActivity.swift @@ -444,50 +444,33 @@ private struct MeshMapperOutcomeDot: View { } } +/// **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 - @State private var deadlineLapsed: Bool - - init(state: MeshMapperActivityAttributes.ContentState) { - self.state = state - let now = Date() - _deadlineLapsed = State( - initialValue: state.phaseEndsAt.map { $0 <= now } ?? false - ) - } - var body: some View { - Group { - if !deadlineLapsed, 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) - } - } - .task(id: state.phaseDeadlineKey) { - let now = Date() - deadlineLapsed = state.phaseEndsAt.map { $0 <= now } ?? false - guard let endsAt = state.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 + 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) } } } @@ -512,19 +495,7 @@ private enum MeshMapperPalette { static let accent = Color.accentColor } -private struct MeshMapperPhaseDeadlineKey: Hashable { - let endsAt: Date? - let durationMs: Int? -} - extension MeshMapperActivityAttributes.ContentState { - fileprivate var phaseDeadlineKey: MeshMapperPhaseDeadlineKey { - MeshMapperPhaseDeadlineKey( - endsAt: phaseEndsAt, - durationMs: phaseDurationMs - ) - } - fileprivate var activeCountdownRange: ClosedRange? { let now = Date() guard let phaseEndsAt, phaseEndsAt > now else { return nil } diff --git a/ios/MeshMapperWatch/Info.plist b/ios/MeshMapperWatch/Info.plist index e481d35..1e2b71b 100644 --- a/ios/MeshMapperWatch/Info.plist +++ b/ios/MeshMapperWatch/Info.plist @@ -24,6 +24,14 @@ WKCompanionAppBundleIdentifier $(MESHMAPPER_BUNDLE_PREFIX) + + WKSupportsLiveActivityLaunchAttributeTypes + WKRunsIndependentlyOfCompanionApp From afe34cf547378fafd1b90898ed10eefde2caa6bb Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 11:38:32 -0700 Subject: [PATCH 65/71] Let the wearer's text size reach the reason a control is dead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every string on this page is Font.system(size:), which is a fixed point size and ignores Dynamic Type entirely — so the page-level dynamicTypeSize(.small ... .large) clamp bounded nothing. blockedReason and the refusal messages rendered at 10 pt in 45% white at every setting, including the largest accessibility sizes, and they are the one thing on the page that explains why a button will not respond. @ScaledMetric keeps the tuned 10 pt base and makes it grow; removing the inert clamp is what lets it reach accessibility sizes at all. 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. The 44 pt targets were checked on hardware and the titles are single-line with tail truncation, so growing them trades a real guarantee for a smaller gain — a narrower deviation, and now a stated one rather than an implicit one. --- ios/MeshMapperWatch/ControlsPage.swift | 28 +++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/ios/MeshMapperWatch/ControlsPage.swift b/ios/MeshMapperWatch/ControlsPage.swift index 585bd92..37196f5 100644 --- a/ios/MeshMapperWatch/ControlsPage.swift +++ b/ios/MeshMapperWatch/ControlsPage.swift @@ -15,6 +15,23 @@ struct ControlsPage: View { 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 { @@ -70,9 +87,10 @@ struct ControlsPage: View { // this twice before. .padding(.bottom, 14) } - // These are fixed pieces of control chrome rather than reading content; - // bounding type preserves the large tap targets on the smallest watch. - .dynamicTypeSize(.small ... .large) + // 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() } @@ -153,7 +171,7 @@ struct ControlsPage: View { private func controlReason(_ reason: String) -> some View { Text(reason) - .font(.system(size: 10, weight: .medium)) + .font(.system(size: reasonFontSize, weight: .medium)) .foregroundStyle(.white.opacity(0.45)) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) @@ -165,7 +183,7 @@ struct ControlsPage: View { Image(systemName: "exclamationmark.circle.fill") Text(refusal) } - .font(.system(size: 10, weight: .medium)) + .font(.system(size: reasonFontSize, weight: .medium)) .foregroundStyle(WatchPalette.armed) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 4) From ac7f64f1fcf6cbc988bcba12a89aa0bdd4e56323 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 11:38:46 -0700 Subject: [PATCH 66/71] Dim the held map, and hold it for twelve seconds instead of twenty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hold's cost model here was wrong. It said "MapKit stays constructed and rendering for the duration", but watchOS suspends this app about a second into a wrist-down — 7.42 s lost of an 8.20 s drop, already measured in this file. Almost none of that rendering happens. What the hold actually costs is a bright basemap held on an OLED always-on display, and that is paid whether or not the app is scheduled. So the levers are lit pixels and seconds of them, not CPU. Both are pulled. A 45% scrim dims the basemap for the length of the hold — Apple's Always-On guidance asks exactly this of "rich images or large areas of color" — and the hold drops to 12 s, which still keeps the map up until nearly 18 s into a glance given the dim lands at 5.9 s. Twenty spent most of its length showing a map to nobody. The scrim is a value change, never a presence change: an overlay that appeared at the dim would re-identify the subtree beneath it and rebuild all of MapKit at the worst possible moment. The readout countdown drops its coarse "<15 sec" ladder and 60 s timeline for the native timer in both luminance states, prefixed "< " while dimmed. Measured on the wrist, the system refreshes that text at a new phase, again 12-18 s later, then about every 12 s — not once a minute as the old comment assumed — so the figure can read high by up to ~18 s. "<" stays true across that gap because remaining time only decreases, and an exact bound beats every rung of the ladder on phases that are only ever 15-60 s long. baselineOffset(1) on the "<" is chosen by eye over the geometric optimum; see the comment for why 2 measures better and looks worse. --- ios/MeshMapperWatch/MapPage.swift | 186 ++++++++++++++++++++---------- 1 file changed, 125 insertions(+), 61 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index b1761b6..8456aca 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -62,7 +62,10 @@ struct MapPage: View { /// 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 - /// roughly double it, for a map the wearer was looking at either way. + /// 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 } @@ -83,12 +86,28 @@ struct MapPage: View { /// position. This can make the map flicker on and disappear while a user is /// looking at it." /// - /// Twenty seconds covers a deliberate glance. It leaves the *radio* alone — - /// `needsMapGeo` above is untouched by the hold — but it is not free: MapKit - /// stays constructed and rendering for the duration, which is the cost the - /// Always-On readout was introduced to avoid. Twenty seconds of it per - /// glance is the trade; a wrist-down's worth would not be. - private static let dimmedMapHold: TimeInterval = 20 + /// **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. @@ -719,8 +738,11 @@ struct MapPage: View { /// once the hold is expired, so nothing keeps asking. /// /// Always-On throttles these updates to roughly one a minute, so the readout - /// may return somewhat after twenty seconds. Nobody is looking by then — the - /// point is that it returns without needing the wrist. + /// 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 { @@ -1585,6 +1607,30 @@ struct MapPage: View { // 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. + .overlay { + Color.black + .opacity(isLuminanceReduced ? 0.45 : 0) + .ignoresSafeArea() + .allowsHitTesting(false) + } } @MapContentBuilder @@ -2212,29 +2258,42 @@ private struct ReadoutPhase: View { ) } + /// **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 { - Group { - if isLuminanceReduced { - // A seconds figure can be nearly a minute wrong while watchOS throttles - // Always-On. Match that cadence explicitly instead of showing false - // precision, and isolate the minute wake-up to this small subtree. - TimelineView(.periodic(from: .now, by: 60)) { context in - phase(at: context.date, usesCoarseCountdown: true) - } - } else { - phase(at: Date(), usesCoarseCountdown: false) + phase(at: Date()) + .task(id: phaseKey) { + await trackDeadline() } - } - .task(id: phaseKey) { - await trackDeadline() - } } @ViewBuilder - private func phase(at date: Date, usesCoarseCountdown: Bool) -> some View { - let lapsed = usesCoarseCountdown - ? snapshot.phaseEndsAt.map { $0 <= date } ?? false - : deadlineLapsed + 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 @@ -2245,43 +2304,47 @@ private struct ReadoutPhase: View { .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) - if usesCoarseCountdown { - if let countdown = coarseCountdown(at: date) { - countdownText(countdown) - } - } else if !lapsed, let endsAt = snapshot.phaseEndsAt, endsAt > date { - Text(timerInterval: date...endsAt, countsDown: true) + 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) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } } } - private func countdownText(_ value: String) -> some View { - Text(value) - .font(.system(size: 24, weight: .bold).monospacedDigit()) - .foregroundStyle(.white) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func coarseCountdown(at date: Date) -> String? { - guard let endsAt = snapshot.phaseEndsAt else { return nil } - let remaining = endsAt.timeIntervalSince(date) - guard remaining > 0 else { return nil } - // These are upper bounds, not estimates. Remaining time only decreases, - // so a tight statement rendered just before Always-On stops refreshing - // stays true afterward. Resolve it from this phase's live deadline on each - // render; retaining a previous phase's bound could make that guarantee - // false when a new, longer countdown begins. - if remaining < 15 { return "<15 sec" } - if remaining < 30 { return "<30 sec" } - if remaining < 60 { return "<1 min" } - return "\(Int(ceil(remaining / 60))) min" - } - @MainActor private func trackDeadline() async { let now = Date() @@ -2291,8 +2354,9 @@ private struct ReadoutPhase: View { deadlineLapsed = snapshot.phaseEndsAt.map { $0 <= now } ?? false } - // The minute timeline owns this boundary under Always-On. Sleeping for an - // exact second there would imply precision the display cannot present. + // 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 } From f4ca85fa0e03e71bd63a11e00b7c64ccd1903846 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 11:39:05 -0700 Subject: [PATCH 67/71] Keep the delivery thread off rendered state, and let a failure be felt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WatchConnectivity documents that "the methods of this protocol are called on a background thread of your app". ingest() already redirected to the main thread; the activation path did not, and reached pendingRefresh, lastSentMapGeoNeeded and mapGeoSuppressionTask — all @Observable-tracked and all read by SwiftUI on the main actor — straight from the delivery queue. On the phone, sessionWatchStateDidChange cleared lastContextData while send(payload:urgent:) read and wrote it on the platform thread, racing the dedupe cache that decides whether the watch gets a snapshot at all. WatchSessionClient is now @MainActor with a nonisolated delegate. Decoding deliberately stays off the main actor: it is the expensive part, it touches no state, and moving a 12 kB JSON parse onto the wrist's main thread would trade one defect for a worse one. Nothing was going to catch this. Checked at -swift-version 6 and -strict-concurrency=complete: both report zero diagnostics against the old code, because WCSessionDelegate is an Objective-C protocol and the isolation never reaches the compiler. The fix does install a guard rail — reintroducing the old pattern now fails to compile even at Swift 5. sessionReachabilityDidChange was implemented on neither side, so the watch's isReachable was a computed read with no observation dependency and never invalidated a view, and the phone's status was only as current as its last activation. It is now stored and event-driven on both. Haptics close the last gap in the cue path: WatchHapticCue has carried kind "success"/"failure"/"notification" all along and play(_:) was never called, so a wrist command that failed after being accepted was announced only on a screen the wearer had usually stopped looking at. It fires from the cue branch of apply(), which is already the one place that establishes a cue is new, fresh and not a redelivery — a view would have to re-derive that gate and would double-fire on the redelivery WatchConnectivity does routinely. Wrist-local preference, default on, under Settings -> Controls. It stays on the wrist for a stronger reason than layout: the only cue the phone sends is the failure of the link itself, so silencing it must not depend on that link. The app now builds both objects in init() so the client and the environment share one WatchSettings — as separate property initialisers they were two instances, and turning haptics off would have silenced only the unread one. Two limits, stated: watchOS ignores play(_:) from a suspended app, so a failure during a long wrist-down is still only seen; and _emitWatchFailure is the sole emission site and hardcodes 'failure', so the other two kinds are unexercised until the phone sends them. --- ios/MeshMapperWatch/MeshMapperWatchApp.swift | 14 +- ios/MeshMapperWatch/SettingsPage.swift | 6 + ios/MeshMapperWatch/WatchSessionClient.swift | 211 ++++++++++++++----- ios/MeshMapperWatch/WatchSettings.swift | 18 ++ ios/Runner/WatchSessionManager.swift | 19 +- 5 files changed, 210 insertions(+), 58 deletions(-) diff --git a/ios/MeshMapperWatch/MeshMapperWatchApp.swift b/ios/MeshMapperWatch/MeshMapperWatchApp.swift index 48754fa..5d49818 100644 --- a/ios/MeshMapperWatch/MeshMapperWatchApp.swift +++ b/ios/MeshMapperWatch/MeshMapperWatchApp.swift @@ -10,10 +10,20 @@ import SwiftUI /// dump, with wrist-local layout preferences in settings. @main struct MeshMapperWatchApp: App { - @State private var client = WatchSessionClient() - @State private var settings = WatchSettings() + @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() diff --git a/ios/MeshMapperWatch/SettingsPage.swift b/ios/MeshMapperWatch/SettingsPage.swift index 855ecb0..d849747 100644 --- a/ios/MeshMapperWatch/SettingsPage.swift +++ b/ios/MeshMapperWatch/SettingsPage.swift @@ -65,6 +65,12 @@ struct SettingsPage: View { "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 diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 0937719..8db55a3 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -1,14 +1,36 @@ 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? @@ -61,7 +83,14 @@ final class WatchSessionClient: NSObject { WCSession.isSupported() ? WCSession.default : nil } - var isReachable: Bool { session?.isReachable ?? false } + /// 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 @@ -93,6 +122,9 @@ final class WatchSessionClient: NSObject { 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() } @@ -130,6 +162,9 @@ final class WatchSessionClient: NSObject { 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 @@ -361,14 +396,54 @@ final class WatchSessionClient: NSObject { 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 + } + + /// 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 - private func ingest(context: [String: Any]) { + /// `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 func ingest(data: Data) { + private nonisolated func ingest(data: Data) { guard let decoded = try? MeshMapperWatchWire.decoder.decode(WatchSnapshot.self, from: data) else { return @@ -377,52 +452,59 @@ final class WatchSessionClient: NSObject { // 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 in self.versionMismatch = true } + Task { @MainActor [weak self] in self?.versionMismatch = true } return } - Task { @MainActor in - let arrival = Date() - self.versionMismatch = false - self.snapshot = decoded - self.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. - self.clearPendingCommand() - - if self.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 = self.lastMapGeoRecoveryRequestAt - if lastRequest == nil || - arrival.timeIntervalSince(lastRequest ?? .distantPast) >= - Self.mapGeoRecoveryThrottle - { - self.lastMapGeoRecoveryRequestAt = arrival - self.sendMapGeoPreference(true, force: true) - } - } + Task { @MainActor [weak self] in self?.apply(decoded) } + } - if let cue = decoded.cue, - Self.isFresh(cue, at: arrival), - self.presentedCueIDs.insert(cue.id).inserted + 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 { - self.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 self.presentedCueIDOrder.count > 64 { - self.presentedCueIDs.remove(self.presentedCueIDOrder.removeFirst()) - } - 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. - self.setLastRefusal(message, from: nil) - } + 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) } } } @@ -430,28 +512,49 @@ final class WatchSessionClient: NSObject { // 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 { - func session( + nonisolated func session( _ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error? ) { - if activationState == .activated { - ingest(context: session.receivedApplicationContext) - if pendingRefresh { - pendingRefresh = false - requestFullSnapshot() - if !mapGeoNeeded { scheduleMapGeoSuppression() } - } + guard activationState == .activated else { return } + ingest(context: session.receivedApplicationContext) + let reachable = session.isReachable + Task { @MainActor [weak self] in + self?.noteReachability(reachable) + self?.completeActivation() } } - func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + nonisolated func session( + _ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any] + ) { ingest(context: applicationContext) } - func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + 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 index 8ab5e9d..92c3c35 100644 --- a/ios/MeshMapperWatch/WatchSettings.swift +++ b/ios/MeshMapperWatch/WatchSettings.swift @@ -19,6 +19,7 @@ final class WatchSettings { 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. @@ -130,6 +131,9 @@ final class WatchSettings { 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. @@ -281,6 +285,20 @@ final class WatchSettings { 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. /// diff --git a/ios/Runner/WatchSessionManager.swift b/ios/Runner/WatchSessionManager.swift index cca3a7c..7b4d82a 100644 --- a/ios/Runner/WatchSessionManager.swift +++ b/ios/Runner/WatchSessionManager.swift @@ -217,8 +217,23 @@ extension WatchSessionManager: WCSessionDelegate { } func sessionWatchStateDidChange(_ session: WCSession) { - // A newly installed or newly paired watch has no context yet. - lastContextData = nil + // 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() } From 6925b340372b8206b47122da39e3f4266c6d77ff Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 15:05:18 -0700 Subject: [PATCH 68/71] Walk the sample fix so a follow animation can be reviewed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sample snapshot has always been stationary, which proves the map renders and can never show that it moves well. Motion is the one thing a follow camera exists for, and the simulator has no other way to produce it. `-MeshMapperSampleWalk YES` advances the fix 15 m every 11 s, which is what the wire actually delivers: `WatchWire.minMoveMeters` is 15 and a geo-only update is suppressed below it, so a walking wearer gets one step every eleven seconds rather than a smooth feed. Reproducing the step is the point — a continuous position would hide exactly the jump worth smoothing. The interval is overridable because the watch simulator renders about one frame per second and stops updating entirely a few seconds after launch. Measured: every frame byte-identical for fourteen seconds with `Text(timerInterval:)` frozen, only the system status-bar clock moving. That is the display sleeping rather than the app dimming — the readout never replaced the map, so the dim hold plainly never ran. A capture run therefore has a few seconds of live rendering, and an eleven second cadence puts no steps inside it. --- ios/MeshMapperWatch/SampleSnapshot.swift | 59 ++++++++++++++++++-- ios/MeshMapperWatch/WatchSessionClient.swift | 33 +++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/ios/MeshMapperWatch/SampleSnapshot.swift b/ios/MeshMapperWatch/SampleSnapshot.swift index c234687..485416d 100644 --- a/ios/MeshMapperWatch/SampleSnapshot.swift +++ b/ios/MeshMapperWatch/SampleSnapshot.swift @@ -20,6 +20,13 @@ import Foundation /// - `-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, @@ -49,9 +56,53 @@ enum SampleSnapshot { private static let originLat = 47.6062 private static let originLon = -122.3321 - static func make() -> WatchSnapshot { + /// 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": @@ -212,9 +263,9 @@ enum SampleSnapshot { : ["passive", "hybrid"], geo: WatchGeo( you: WatchPosition( - lat: originLat + 0.006, - lon: originLon + 0.014, - headingDeg: 72, + lat: originLat + 0.006 + walkLat, + lon: originLon + 0.014 + walkLon, + headingDeg: walkBearingDeg, accuracyM: 8, fixedAtMs: now ), diff --git a/ios/MeshMapperWatch/WatchSessionClient.swift b/ios/MeshMapperWatch/WatchSessionClient.swift index 8db55a3..7118cc5 100644 --- a/ios/MeshMapperWatch/WatchSessionClient.swift +++ b/ios/MeshMapperWatch/WatchSessionClient.swift @@ -114,6 +114,7 @@ final class WatchSessionClient: NSObject { if SampleSnapshot.isEnabled { snapshot = SampleSnapshot.make() markSnapshotReceived() + startSampleWalkIfRequested() return } #endif @@ -409,6 +410,38 @@ final class WatchSessionClient: NSObject { 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 From d8f3bbe095c9be9f6a8aa8cbbdc3e64eff684d52 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 15:05:48 -0700 Subject: [PATCH 69/71] Slide the map to a new fix instead of cutting to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The phone's movement gate is 15 m, so a walking wearer's fix advances in one step every eleven seconds rather than continuously. At the 250 m default zoom that is about 12 pt of screen arriving at once, which reads as a jump rather than as travel. Easing the camera alone was already rejected in a comment here, and correctly: `fix` comes from a snapshot the transport replaces in its own transaction, so the annotation has moved before this page reacts, and an eased camera would leave the puck sliding off centre and snapping back. `displayedFix` is the fix: the puck renders from view state that changes inside the same `withAnimation` as the camera, so both travel on one curve and the world moves beneath a stationary puck. `applyRegion`'s `animated: false` now means "add no animation of your own" — the follow path inherits the ambient transaction, while first placement, the anchor and the post-zoom correction still cut. Frozen under reduced luminance on the same rule the countdown bar follows. Geo can still arrive inside the dim hold, because `needsMapGeo` goes false at the dim while suppression is only scheduled fifteen seconds out, so the gate is load-bearing rather than decorative. A step over 100 m also cuts: updates are at least two seconds apart, so even a sprint stays well under that, and anything further means delivery was interrupted and sliding would draw a whoosh through ground nobody walked. Verified through the sample walk on a 46 mm simulator: the puck held (207.5, 233.5) px across three steps while a fixed ping translated west-south-west at a consistent 12.3 px per step, the correct direction for the harness's 72 degree bearing, with puck and camera in exact lockstep. That is the property the old comment predicted would break. Whether the motion eases rather than cuts is not verified — the simulator renders about one frame per second, so a 0.7 s animation has no intermediate frames to catch, and stretching it to six seconds failed the same way. Smoothness wants a wrist. --- ios/MeshMapperWatch/MapPage.swift | 113 +++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 8 deletions(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 8456aca..548e9ad 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -185,6 +185,22 @@ struct MapPage: View { @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. @@ -1108,7 +1124,7 @@ struct MapPage: View { ) } #endif - recenterIfFollowing() + 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() @@ -1140,6 +1156,10 @@ struct MapPage: View { // 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 @@ -1703,8 +1723,11 @@ struct MapPage: View { @MapContentBuilder private var fixMarker: some MapContent { - if let fix, let you = snapshot?.geo.you { - Annotation("", coordinate: fix) { + // `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) @@ -1715,6 +1738,76 @@ struct MapPage: View { // 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 @@ -1739,9 +1832,10 @@ struct MapPage: View { // 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 is different: the fix - // coordinate has already changed in this frame, and animating the camera - // after it makes the puck wander before the map catches up. + // 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) } @@ -1832,8 +1926,11 @@ struct MapPage: View { camera = .region(region) } } else { - // First placement and GPS steps cut, so the puck stays visually fixed - // while the world moves beneath it. + // "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) } } From 161a96ca18065177c988b286c276ee1707ae451a Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 15:50:22 -0700 Subject: [PATCH 70/71] Darken the held map to what a powerlog says it costs The dim hold keeps the basemap on an OLED display that is already dimmed, and the scrim over it was set to 0.45 by judgement. A watch sysdiagnose reports content brightness per sample as AvgAPL, so the surfaces can now be priced instead: across the 2026-08-16 walk a bright map sat at APL 135, the scrim plus Always-On took it to 72, and the readout that eventually replaces it sat at 30.5 in the same dim state. The held map was still 2.4x the readout's content brightness, and on an OLED that is the whole cost. Luminance scales as basemap * (1 - opacity), and the measurements agree with the model: 72 at 0.45 implies an unscrimmed 131 against a measured 135. Solving for the readout's 30.5 with a little margin gives 0.70. Estimated saving is around 7% of a walk, across the ~24.5 minutes of dimmed map time the same log measured. The hold's duration is deliberately untouched, because it is not what binds. The same walk found 63 map episodes with a median of 32.7 s against the ~17.9 s this hold is supposed to cap them at: the ticker is throttled to roughly one wake a minute under Always On, so the readout returns when watchOS gets round to it rather than when dimmedMapHold expires. The doc comment on dimmedMapHoldTicker predicted exactly this and warned that a shorter hold makes the overshoot proportionally worse. Shortening the constant would not shorten the episodes. Darkening the pixels they spend does. Wants a wrist: 0.70 is derived from power, not from legibility, and whether the held map still reads at arm's length is not something a powerlog can answer. --- ios/MeshMapperWatch/MapPage.swift | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/ios/MeshMapperWatch/MapPage.swift b/ios/MeshMapperWatch/MapPage.swift index 548e9ad..d7e2bb4 100644 --- a/ios/MeshMapperWatch/MapPage.swift +++ b/ios/MeshMapperWatch/MapPage.swift @@ -1645,9 +1645,29 @@ struct MapPage: View { // 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.45 : 0) + .opacity(isLuminanceReduced ? 0.70 : 0) .ignoresSafeArea() .allowsHitTesting(false) } From 8e8f421c2f833a656cc1a1c456c157cd52ce0bce Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 16 Aug 2026 15:50:34 -0700 Subject: [PATCH 71/71] Stop a millisecond of deadline drift bypassing the update throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit urgencyKey carried phaseEndsAt at millisecond resolution, so any recomputation of the deadline made the key differ and forced an immediate ActivityKit update past the 15 s non-urgent throttle, even though the phase had not changed. The phone's powerlog measures what that cost. Across the 2026-08-16 walk, PLApplicationAgent_EventPoint_LiveActivityUpdates recorded 720 updates for this app 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 existing 200 ms debounce should already have absorbed. The extension is the second largest energy consumer on the phone and 67.9% of its draw is CPU, so this is update volume rather than per-update work: every @State, Task and withAnimation was already removed from that view. Rounding to seconds cannot lose anything the surface can show. The widget renders the deadline through Text(timerInterval:) and ProgressView(timerInterval:), both of which display whole seconds, while a genuine phase change moves the deadline by seconds at minimum and still bypasses the throttle as intended. It costs the watch too, which is why this is not only a phone fix: ActivityKit updates are mirrored to the paired watch for the Smart Stack and count against its Live Activity budget. Verification wants a fresh sysdiagnose after the next walk — the update count in that table is the number to watch. --- .../live_activity/live_activity_models.dart | 22 +++++++++++++++- .../live_activity_models_test.dart | 25 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/lib/services/live_activity/live_activity_models.dart b/lib/services/live_activity/live_activity_models.dart index f8b7666..95a897d 100644 --- a/lib/services/live_activity/live_activity_models.dart +++ b/lib/services/live_activity/live_activity_models.dart @@ -149,7 +149,27 @@ class LiveActivitySnapshot { phase.wireValue, phaseTitle, phaseDetail ?? '', - phaseEndsAt?.millisecondsSinceEpoch ?? 0, + // 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 ?? '', diff --git a/test/services/live_activity/live_activity_models_test.dart b/test/services/live_activity/live_activity_models_test.dart index cb186de..e9233cb 100644 --- a/test/services/live_activity/live_activity_models_test.dart +++ b/test/services/live_activity/live_activity_models_test.dart @@ -89,6 +89,31 @@ void main() { make().urgencyKey, isNot(make(phaseEndsAt: now.add(const Duration(seconds: 15))).urgencyKey), ); + + // A deadline that shifts by milliseconds is not news, and must not buy a + // trip past the non-urgent throttle. The phone's powerlog measured 720 + // Live Activity updates across a 90-minute walk — one every 7.5 s, 116 of + // them less than a second apart — against Apple Fitness's 2 in the same + // window, because this key carried the deadline at millisecond resolution. + // + // Nothing visible is lost: the widget renders the deadline through + // `Text(timerInterval:)` and `ProgressView(timerInterval:)`, which show + // whole seconds. + expect( + make(phaseEndsAt: now).urgencyKey, + make(phaseEndsAt: now.add(const Duration(milliseconds: 120))).urgencyKey, + ); + // Rounding, not truncation, so the boundary is not a cliff either way. + expect( + make(phaseEndsAt: now).urgencyKey, + make(phaseEndsAt: now.subtract(const Duration(milliseconds: 120))) + .urgencyKey, + ); + // A whole second still counts as news. + expect( + make(phaseEndsAt: now).urgencyKey, + isNot(make(phaseEndsAt: now.add(const Duration(seconds: 1))).urgencyKey), + ); }); test('normalizes non-finite repeater values before encoding', () {