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